incanto 0.26.0 → 0.28.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.
@@ -4,7 +4,7 @@ import { t as Rng } from "./rng-DP-SR7eg.js";
4
4
  import { i as getNodeSchema, l as registerNode } from "./registry-BVJ2HbCn.js";
5
5
  import { t as createNoise2D } from "./noise-CGUMx44x.js";
6
6
  import { i as applyParticlePreset, r as PARTICLE_PRESET_NAMES, t as ParticleSim } from "./particle-sim-Bw7hB93B.js";
7
- import { A as Water3D, F as PhysicsBody3D, H as colliderFootDrop, I as RigidBody3D, L as StaticBody3D, M as WaterCutout3D, N as Area3D, P as CharacterBody3D, R as Node3D } from "./gameplay-Ddk13pQ6.js";
7
+ import { A as Water3D, F as PhysicsBody3D, H as colliderFootDrop, I as RigidBody3D, L as StaticBody3D, M as WaterCutout3D, N as Area3D, P as CharacterBody3D, R as Node3D } from "./gameplay-DEG-TP7D.js";
8
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, CylinderGeometry, DataTexture, DirectionalLight, DoubleSide, DynamicDrawUsage, Euler, Group, IcosahedronGeometry, ImageBitmapLoader, InstancedBufferAttribute, InstancedMesh, LinearFilter, LoopOnce, LoopRepeat, Matrix4, Mesh, MeshBasicMaterial, MeshDepthMaterial, MeshPhysicalMaterial, MeshStandardMaterial, NearestFilter, NoBlending, NormalBlending, PerspectiveCamera, PlaneGeometry, PointLight, Points, PointsMaterial, Quaternion, QuaternionKeyframeTrack, RGBADepthPacking, RGBAFormat, RepeatWrapping, SRGBColorSpace, ShaderChunk, ShaderMaterial, SphereGeometry, Texture, TextureLoader, UniformsLib, UniformsUtils, Vector3, Vector4, VectorKeyframeTrack } from "three";
10
10
  import { clone } from "three/addons/utils/SkeletonUtils.js";
@@ -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;
@@ -2802,6 +2832,37 @@ function firstTerrain(node) {
2802
2832
  return null;
2803
2833
  }
2804
2834
  //#endregion
2835
+ //#region src/3d/water/wet-ground.ts
2836
+ function isWetSurface(node) {
2837
+ return typeof node?._waterYAt === "function";
2838
+ }
2839
+ /** Every water surface in the tree the node belongs to. */
2840
+ function collectWetSurfaces(from) {
2841
+ let root = from;
2842
+ while (root.parent) root = root.parent;
2843
+ const out = [];
2844
+ const walk = (node) => {
2845
+ if (node.visible !== false && isWetSurface(node)) out.push(node);
2846
+ for (const child of node.children) walk(child);
2847
+ };
2848
+ walk(root);
2849
+ return out;
2850
+ }
2851
+ /**
2852
+ * Is this spot under water deep enough to drown a plant?
2853
+ *
2854
+ * `margin` is the depth a blade tolerates — a wet edge is still a bank, and
2855
+ * culling everything the water so much as touches leaves a bald halo around
2856
+ * every stream.
2857
+ */
2858
+ function isUnderWater(surfaces, x, z, groundY, margin = .06) {
2859
+ for (let i = 0; i < surfaces.length; i++) {
2860
+ const y = surfaces[i]?._waterYAt(x, z);
2861
+ if (y !== null && y !== void 0 && y - groundY > margin) return true;
2862
+ }
2863
+ return false;
2864
+ }
2865
+ //#endregion
2805
2866
  //#region src/3d/nodes/flowers-3d.ts
2806
2867
  /** Named density presets, plants per m² — the vibe-coding dial (풍성하게 /
2807
2868
  * 듬성듬성 / 없게). Numbers are accepted too. */
@@ -2870,6 +2931,7 @@ var Flowers3D = class extends Node3D {
2870
2931
  clustering: { default: .6 },
2871
2932
  sway: { default: .5 },
2872
2933
  drape: { default: null },
2934
+ avoidWater: { default: true },
2873
2935
  terrain: { default: "" }
2874
2936
  };
2875
2937
  /** 'lush' | 'sparse' | 'none' | plants per m² (load-time check + budget). */
@@ -2897,6 +2959,12 @@ var Flowers3D = class extends Node3D {
2897
2959
  * draping entirely.
2898
2960
  */
2899
2961
  drape = null;
2962
+ /**
2963
+ * Keep plants out of standing and running water (default true) — a meadow a
2964
+ * stream runs through has a bank of flowers, not flowers in the current.
2965
+ * Set false for water lilies and marsh planting.
2966
+ */
2967
+ avoidWater = true;
2900
2968
  /** Drape target: a node path to the Terrain3D. Empty = auto-find the first
2901
2969
  * Terrain3D in the tree (the zero-config default when `drape` is on). */
2902
2970
  terrain = "";
@@ -2996,13 +3064,20 @@ var Flowers3D = class extends Node3D {
2996
3064
  const density = resolveFlowerDensity(this.density);
2997
3065
  const count = Math.min(Math.floor((this.area[0] ?? 0) * (this.area[1] ?? 0) * density), MAX_PLANTS);
2998
3066
  if (count <= 0) return;
2999
- const placements = placeFlowerPlants({
3067
+ let placements = placeFlowerPlants({
3000
3068
  seed: this.seed,
3001
3069
  areaX: this.area[0] ?? 0,
3002
3070
  areaZ: this.area[1] ?? 0,
3003
3071
  count,
3004
3072
  clustering: this.clustering
3005
3073
  });
3074
+ if (this.avoidWater) {
3075
+ const surfaces = collectWetSurfaces(this);
3076
+ if (surfaces.length > 0) {
3077
+ const [ox, oy, oz] = worldXZY(this);
3078
+ placements = placements.filter((p) => !isUnderWater(surfaces, p.x + ox, p.z + oz, this.bedY(p.x, p.z) + oy));
3079
+ }
3080
+ }
3006
3081
  const varieties = this.varieties.length > 0 ? this.varieties : FLOWER_VARIETIES;
3007
3082
  const buckets = varieties.map(() => []);
3008
3083
  for (const p of placements) buckets[Math.min(Math.floor(p.variety01 * varieties.length), varieties.length - 1)].push(p);
@@ -4257,6 +4332,7 @@ var Foliage3D = class Foliage3D extends Node3D {
4257
4332
  flowers: { default: 0 },
4258
4333
  seed: { default: 1 },
4259
4334
  drape: { default: null },
4335
+ avoidWater: { default: true },
4260
4336
  terrain: { default: "" },
4261
4337
  renderOrder: { default: 2 }
4262
4338
  };
@@ -4323,6 +4399,14 @@ var Foliage3D = class Foliage3D extends Node3D {
4323
4399
  * a terrain appears), false disables draping entirely.
4324
4400
  */
4325
4401
  drape = null;
4402
+ /**
4403
+ * Keep blades out of standing and running water (default true). Grass does
4404
+ * not grow in a stream: any instance whose ground sits under a `Water3D` or
4405
+ * `River3D` surface is dropped, which is what stops a creek that cut its bed
4406
+ * through a meadow from filling with waving blades. Set false for reeds and
4407
+ * paddy fields, where the plant IS in the water.
4408
+ */
4409
+ avoidWater = true;
4326
4410
  /** Drape target: a node path to the Terrain3D. Empty = auto-find the first
4327
4411
  * Terrain3D in the tree (the zero-config default when `drape` is on). */
4328
4412
  terrain = "";
@@ -4552,6 +4636,22 @@ var Foliage3D = class Foliage3D extends Node3D {
4552
4636
  * fraction of instances into a SECOND InstancedMesh of 5-vertex flower
4553
4637
  * heads (white/yellow/violet per instance).
4554
4638
  */
4639
+ /**
4640
+ * Drop the instances standing in water. The ground each blade drapes onto is
4641
+ * already known here, so the test is one question per candidate — and only
4642
+ * the ones a water surface actually covers pay for it.
4643
+ */
4644
+ dryOnly(placements) {
4645
+ if (!this.avoidWater) return placements;
4646
+ const surfaces = collectWetSurfaces(this);
4647
+ if (surfaces.length === 0) return placements;
4648
+ const [ox, , oz] = worldXZY(this);
4649
+ return placements.filter((p) => !isUnderWater(surfaces, p.x + ox, p.z + oz, this.bedY(p.x, p.z) + this.worldY()));
4650
+ }
4651
+ /** This field's own world height (the bed sampler works in local Y). */
4652
+ worldY() {
4653
+ return worldXZY(this)[1];
4654
+ }
4555
4655
  buildMeshField(count) {
4556
4656
  const placements = placeGrass({
4557
4657
  seed: this.seed,
@@ -4560,7 +4660,7 @@ var Foliage3D = class Foliage3D extends Node3D {
4560
4660
  count,
4561
4661
  density: this.density * MESH_DENSITY_MULT
4562
4662
  });
4563
- const kept = this.coverage >= 1 ? placements : placements.filter((p) => coverageNoiseAt(p.x, p.z, this.seed) <= this.coverage);
4663
+ const kept = this.dryOnly(this.coverage >= 1 ? placements : placements.filter((p) => coverageNoiseAt(p.x, p.z, this.seed) <= this.coverage));
4564
4664
  const flowerCount = this.flowers > 0 ? Math.floor(kept.length * this.flowers) : 0;
4565
4665
  const flowerSlots = /* @__PURE__ */ new Set();
4566
4666
  for (let k = 0; k < flowerCount; k++) flowerSlots.add(Math.floor((k + .5) * kept.length / flowerCount));
@@ -4670,7 +4770,7 @@ var Foliage3D = class Foliage3D extends Node3D {
4670
4770
  count,
4671
4771
  density: this.density * TUFT_DENSITY_MULT
4672
4772
  });
4673
- const kept = this.coverage >= 1 ? placements : placements.filter((p) => coverageNoiseAt(p.x, p.z, this.seed) <= this.coverage);
4773
+ const kept = this.dryOnly(this.coverage >= 1 ? placements : placements.filter((p) => coverageNoiseAt(p.x, p.z, this.seed) <= this.coverage));
4674
4774
  this.tuftTime = { value: this.time };
4675
4775
  this.tuftStrength = { value: this.sway * TUFT_SWAY_TO_WIND };
4676
4776
  const material = new MeshStandardMaterial({
@@ -4773,23 +4873,32 @@ var Foliage3D = class Foliage3D extends Node3D {
4773
4873
  roughness: 1
4774
4874
  });
4775
4875
  const mesh = new InstancedMesh(buildBladeGeometry(BLADE_WIDTH[this.kind] ?? .08), material, Math.max(count, 1));
4776
- mesh.count = count;
4777
4876
  mesh.receiveShadow = true;
4778
4877
  const rng = new Rng(this.seed);
4779
4878
  const halfX = (this.area[0] ?? 0) / 2;
4780
4879
  const halfZ = (this.area[1] ?? 0) / 2;
4781
4880
  const colorA = colorScratchA$1.set(this.colorA);
4782
4881
  const colorB = colorScratchB$1.set(this.colorB);
4882
+ const wet = this.avoidWater ? collectWetSurfaces(this) : [];
4883
+ const [ox, oy, oz] = worldXZY(this);
4884
+ let placed = 0;
4783
4885
  for (let i = 0; i < count; i++) {
4784
4886
  const sx = rng.range(-halfX, halfX);
4785
4887
  const sz = rng.range(-halfZ, halfZ);
4786
- positionScratch$1.set(sx, this.bedY(sx, sz), sz);
4787
- quaternionScratch$1.setFromAxisAngle(Y_AXIS$1, rng.range(0, Math.PI * 2));
4788
- scaleScratch$2.set(1, this.height * rng.range(HEIGHT_JITTER$1[0], HEIGHT_JITTER$1[1]), 1);
4888
+ const rotY = rng.range(0, Math.PI * 2);
4889
+ const jitter = rng.range(HEIGHT_JITTER$1[0], HEIGHT_JITTER$1[1]);
4890
+ const tint = rng.next();
4891
+ const bed = this.bedY(sx, sz);
4892
+ if (wet.length > 0 && isUnderWater(wet, sx + ox, sz + oz, bed + oy)) continue;
4893
+ positionScratch$1.set(sx, bed, sz);
4894
+ quaternionScratch$1.setFromAxisAngle(Y_AXIS$1, rotY);
4895
+ scaleScratch$2.set(1, this.height * jitter, 1);
4789
4896
  matrixScratch$1.compose(positionScratch$1, quaternionScratch$1, scaleScratch$2);
4790
- mesh.setMatrixAt(i, matrixScratch$1);
4791
- mesh.setColorAt(i, colorScratchC$1.copy(colorA).lerp(colorB, rng.next()));
4897
+ mesh.setMatrixAt(placed, matrixScratch$1);
4898
+ mesh.setColorAt(placed, colorScratchC$1.copy(colorA).lerp(colorB, tint));
4899
+ placed++;
4792
4900
  }
4901
+ mesh.count = placed;
4793
4902
  mesh.instanceMatrix.needsUpdate = true;
4794
4903
  if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
4795
4904
  this.meshes = [mesh];
@@ -6422,6 +6531,81 @@ function softSprite() {
6422
6531
  }
6423
6532
  //#endregion
6424
6533
  //#region src/3d/water/river.ts
6534
+ /**
6535
+ * Self-carved bed. Sized off the WATER COLUMN, never off the width: a channel's
6536
+ * banks are as steep as banks are, and a taper scaled to width turns a wide
6537
+ * river into a shallow saucer the water floods far past its edge.
6538
+ */
6539
+ /** Trench depth, in water columns. */
6540
+ const CARVE_DEPTH = 2.2;
6541
+ /** Bank blend beyond the flat bed, in water columns. */
6542
+ const CARVE_BANK = 2;
6543
+ /** Where the waterline crosses that blend — smoothstep(t) = 1 − 1/CARVE_DEPTH.
6544
+ * Knowing it is what lets the floor be narrowed so the WATER ends up the
6545
+ * authored width instead of the bed doing so. */
6546
+ const CARVE_WATERLINE_T = .52;
6547
+ /** Never cut the floor away entirely, however steep the bank works out. */
6548
+ const CARVE_FLOOR_MIN = .3;
6549
+ /** A trench thinner than this many terrain cells cannot survive the grid it is
6550
+ * sampled on — it smooths away to nothing and the creek is back on raw ground.
6551
+ * Widening it is the honest failure: better a bed too wide than no bed. */
6552
+ const CARVE_MIN_CELLS = 1.5;
6553
+ /** Deepest the bed may be cut, in water columns — the budget for digging
6554
+ * THROUGH a rise so the water keeps flowing, before it reads as a canyon. */
6555
+ const CARVE_MAX_DEPTH = 7;
6556
+ /** The fall the carved floor keeps even where the ground does not (m per m). */
6557
+ const CARVE_MIN_GRADE = .004;
6558
+ /**
6559
+ * The trench a river digs for itself, as {@link ChannelSpec}s for Terrain3D.
6560
+ *
6561
+ * Shared on purpose: `River3D` cuts this at load, and anything that has to
6562
+ * PLACE things against the result — a scene generator putting a player, a
6563
+ * bridge and a boulder field on the ground — computes the identical cut
6564
+ * instead of approximating it. A generator that guesses differently spawns the
6565
+ * player inside a hill.
6566
+ *
6567
+ * The floor only ever FALLS. Water does not climb, so a bed that climbs is a
6568
+ * river that stops and buries itself; carrying the running minimum downstream
6569
+ * digs through a rise instead, and the cap keeps that from turning a hill into
6570
+ * a canyon.
6571
+ */
6572
+ function riverCarveChannels(opts) {
6573
+ if (opts.path.length < 2) return [];
6574
+ const line = resamplePath(opts.path, Math.max(opts.cell, opts.depth));
6575
+ if (line.length < 2) return [];
6576
+ const total = line[line.length - 1].along || 1;
6577
+ const ground = line.map((s) => opts.groundAt(s.x, s.z));
6578
+ const baseCut = opts.depth * CARVE_DEPTH;
6579
+ const maxCut = opts.depth * CARVE_MAX_DEPTH;
6580
+ const floor = new Array(line.length);
6581
+ for (let i = 0; i < line.length; i++) {
6582
+ const wanted = ground[i] - baseCut;
6583
+ if (i === 0) {
6584
+ floor[i] = wanted;
6585
+ continue;
6586
+ }
6587
+ const run = line[i].along - line[i - 1].along;
6588
+ const carried = floor[i - 1] - run * CARVE_MIN_GRADE;
6589
+ floor[i] = Math.max(Math.min(wanted, carried), ground[i] - maxCut);
6590
+ }
6591
+ const out = [];
6592
+ for (let i = 1; i < line.length; i++) {
6593
+ const a = line[i - 1];
6594
+ const b = line[i];
6595
+ const cut = Math.max((ground[i - 1] + ground[i]) / 2 - (floor[i - 1] + floor[i]) / 2, 0);
6596
+ if (cut <= .001) continue;
6597
+ const half = widthAlong(opts.widths, a.along / total) / 2;
6598
+ const bank = Math.max(opts.depth * CARVE_BANK, opts.cell * CARVE_MIN_CELLS);
6599
+ const floorHalf = Math.max(half - bank * CARVE_WATERLINE_T, half * CARVE_FLOOR_MIN, opts.cell * .5);
6600
+ out.push({
6601
+ path: [[a.x, a.z], [b.x, b.z]],
6602
+ width: floorHalf * 2,
6603
+ depth: cut,
6604
+ taper: bank
6605
+ });
6606
+ }
6607
+ return out;
6608
+ }
6425
6609
  /** Dense Catmull-Rom taps per control segment before arc-length resampling. */
6426
6610
  const SPLINE_TAPS = 24;
6427
6611
  /** Box-filter half-window (samples) that irons kinks out of the bed profile. */
@@ -6430,6 +6614,22 @@ const PROFILE_SMOOTH = 3;
6430
6614
  const SLOPE_GAIN = 4;
6431
6615
  const SPEED_MIN = .05;
6432
6616
  const SPEED_MAX = 12;
6617
+ /** Taps per side when reading the cross-section (rim height, wetted edge). */
6618
+ const SECTION_TAPS = 14;
6619
+ /** How far out a station looks for the bank that holds it, as a multiple of
6620
+ * its half-width and of its depth. Wider than the water on purpose: a river
6621
+ * running down the middle of a broad channel is held by walls it never
6622
+ * touches, and a scan that stopped at the waterline would call that a spill. */
6623
+ const SCAN_WIDTHS = 3;
6624
+ const SCAN_DEPTHS = 5;
6625
+ /** Thinnest water a reach keeps when the ground refuses to hold any (m). */
6626
+ const MIN_FILM = .04;
6627
+ /** Half-window (stations) the wetted widths are box-smoothed over. */
6628
+ const WIDTH_SMOOTH = 2;
6629
+ /** How far a station looks sideways for the bank that holds its water. */
6630
+ function scanReach(halfWidth, depth) {
6631
+ return Math.max(halfWidth * SCAN_WIDTHS, halfWidth + depth * SCAN_DEPTHS);
6632
+ }
6433
6633
  /**
6434
6634
  * Smooth the control path and walk it at even arc-length steps. The step is
6435
6635
  * nudged so the last sample lands exactly on the mouth — an odd short segment
@@ -6549,26 +6749,102 @@ function buildRiverRings(opts) {
6549
6749
  const s = smoothed[i];
6550
6750
  profile[i] = i === 0 ? s : Math.min(s, profile[i - 1]);
6551
6751
  }
6752
+ const fit = opts.fit === true;
6753
+ const levels = new Array(samples.length);
6754
+ const rims = new Array(samples.length);
6755
+ for (let i = 0; i < samples.length; i++) {
6756
+ const s = samples[i];
6757
+ const wanted = profile[i] + opts.depth;
6758
+ if (!fit) {
6759
+ rims[i] = [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY];
6760
+ levels[i] = wanted;
6761
+ continue;
6762
+ }
6763
+ const reach = scanReach(halfWidths[i], opts.depth);
6764
+ const px = -s.tz;
6765
+ const pz = s.tx;
6766
+ let rimL = Number.NEGATIVE_INFINITY;
6767
+ let rimR = Number.NEGATIVE_INFINITY;
6768
+ for (let k = 1; k <= SECTION_TAPS; k++) {
6769
+ const d = k / SECTION_TAPS * reach;
6770
+ rimL = Math.max(rimL, opts.bedAt(s.x + px * d, s.z + pz * d));
6771
+ rimR = Math.max(rimR, opts.bedAt(s.x - px * d, s.z - pz * d));
6772
+ }
6773
+ rims[i] = [rimL, rimR];
6774
+ levels[i] = Math.min(wanted, rimL, rimR);
6775
+ }
6776
+ for (let i = 0; i < levels.length; i++) {
6777
+ let v = levels[i];
6778
+ if (fit) v = Math.max(v, bed[i] + MIN_FILM);
6779
+ if (i > 0) v = Math.min(v, levels[i - 1]);
6780
+ const rim = rims[i];
6781
+ v = Math.min(v, Math.max(Math.min(rim[0], rim[1]), bed[i] + MIN_FILM));
6782
+ levels[i] = v;
6783
+ }
6784
+ const wetted = samples.map((s, i) => {
6785
+ const half = halfWidths[i];
6786
+ if (!fit) return [half, half];
6787
+ const reach = scanReach(half, opts.depth);
6788
+ const level = levels[i];
6789
+ const px = -s.tz;
6790
+ const pz = s.tx;
6791
+ const edge = (side) => {
6792
+ for (let k = 1; k <= SECTION_TAPS; k++) {
6793
+ const d = k / SECTION_TAPS * reach;
6794
+ if (opts.bedAt(s.x + px * d * side, s.z + pz * d * side) >= level) return d;
6795
+ }
6796
+ return -half;
6797
+ };
6798
+ return [edge(1), edge(-1)];
6799
+ });
6800
+ const smoothWetted = wetted.map((_, i) => {
6801
+ let l = 0;
6802
+ let r = 0;
6803
+ let n = 0;
6804
+ for (let k = -2; k <= WIDTH_SMOOTH; k++) {
6805
+ const w = wetted[Math.min(Math.max(i + k, 0), wetted.length - 1)];
6806
+ l += Math.abs(w[0]);
6807
+ r += Math.abs(w[1]);
6808
+ n++;
6809
+ }
6810
+ const raw = wetted[i];
6811
+ return [Math.min(l / n, Math.abs(raw[0])), Math.min(r / n, Math.abs(raw[1]))];
6812
+ });
6552
6813
  const meanHalf = halfWidths.reduce((a, b) => a + b, 0) / halfWidths.length || 1;
6553
6814
  return samples.map((s, i) => {
6554
- const surfaceY = profile[i] + opts.depth;
6815
+ const surfaceY = levels[i];
6555
6816
  const prev = Math.max(0, i - 1);
6556
6817
  const next = Math.min(samples.length - 1, i + 1);
6557
6818
  const run = samples[next].along - samples[prev].along;
6558
- const drop = (profile[prev] + opts.depth - (profile[next] + opts.depth)) / (run || 1);
6819
+ const drop = (levels[prev] - levels[next]) / (run || 1);
6559
6820
  const slope = Math.max(0, drop);
6560
6821
  const halfWidth = halfWidths[i];
6822
+ const wet = smoothWetted[i];
6561
6823
  const speed = Math.min(Math.max(opts.flowSpeed * Math.sqrt(meanHalf / halfWidth) * (1 + SLOPE_GAIN * slope), SPEED_MIN), SPEED_MAX);
6824
+ const rawWet = wetted[i];
6562
6825
  return {
6563
6826
  ...s,
6564
6827
  halfWidth,
6828
+ halfLeft: Math.max(wet[0], .05),
6829
+ halfRight: Math.max(wet[1], .05),
6830
+ spillLeft: rawWet[0] < 0,
6831
+ spillRight: rawWet[1] < 0,
6565
6832
  surfaceY,
6566
6833
  slope,
6567
6834
  speed
6568
6835
  };
6569
6836
  });
6570
6837
  }
6571
- /** Ribbon mesh for the stations — flat across, draped along, attribute-rich. */
6838
+ /**
6839
+ * Ribbon mesh for the stations: level across the channel, draped along it, and
6840
+ * attribute-rich.
6841
+ *
6842
+ * Level across ONLY while the ground holds it. Where a bank has fallen away —
6843
+ * the outside of a bend, a reach traversing a hillside, the lip above a fall —
6844
+ * a level ribbon would hang in the air, so those vertices drop to follow the
6845
+ * ground down instead. Water pouring over a lip is a sheet on the rock; it is
6846
+ * never a slab of blue floating over the grass.
6847
+ */
6572
6848
  function buildRiverGeometry(rings, opts) {
6573
6849
  const across = Math.max(1, Math.round(opts.acrossSegments));
6574
6850
  const perRing = across + 1;
@@ -6582,6 +6858,7 @@ function buildRiverGeometry(rings, opts) {
6582
6858
  const indices = new Uint32Array(quads * 6);
6583
6859
  for (let r = 0; r < rings.length; r++) {
6584
6860
  const ring = rings[r];
6861
+ const columnCap = Math.max(ring.surfaceY - sampleBed(opts, ring.x, ring.z), MIN_FILM);
6585
6862
  const px = -ring.tz;
6586
6863
  const pz = ring.tx;
6587
6864
  const nLen = Math.hypot(ring.slope, 1);
@@ -6590,12 +6867,15 @@ function buildRiverGeometry(rings, opts) {
6590
6867
  const nz = ring.tz * ring.slope / nLen;
6591
6868
  for (let c = 0; c <= across; c++) {
6592
6869
  const t = c / across * 2 - 1;
6593
- const offset = t * ring.halfWidth * opts.bankOvershoot;
6870
+ const offset = t * (t < 0 ? ring.halfRight : ring.halfLeft) * opts.bankOvershoot;
6594
6871
  const x = ring.x + px * offset;
6595
6872
  const z = ring.z + pz * offset;
6596
6873
  const i = r * perRing + c;
6874
+ const bed = sampleBed(opts, x, z);
6875
+ const cap = (t < 0 ? ring.spillRight : ring.spillLeft) ? columnCap + (MIN_FILM - columnCap) * Math.abs(t) : columnCap;
6876
+ const y = Math.min(ring.surfaceY, bed + cap);
6597
6877
  positions[i * 3] = x;
6598
- positions[i * 3 + 1] = ring.surfaceY;
6878
+ positions[i * 3 + 1] = y;
6599
6879
  positions[i * 3 + 2] = z;
6600
6880
  normals[i * 3] = nx;
6601
6881
  normals[i * 3 + 1] = ny;
@@ -6605,7 +6885,7 @@ function buildRiverGeometry(rings, opts) {
6605
6885
  flow[i * 2] = ring.tx;
6606
6886
  flow[i * 2 + 1] = ring.tz;
6607
6887
  river[i * 4] = ring.speed;
6608
- river[i * 4 + 1] = Math.max(0, ring.surfaceY - sampleBed(opts, x, z));
6888
+ river[i * 4 + 1] = Math.max(0, y - bed);
6609
6889
  river[i * 4 + 2] = ring.slope;
6610
6890
  river[i * 4 + 3] = t * opts.bankOvershoot;
6611
6891
  }
@@ -6689,8 +6969,10 @@ function hitFrom(a, b, t, x, z, capped) {
6689
6969
  const tl = Math.hypot(tx, tz) || 1;
6690
6970
  tx /= tl;
6691
6971
  tz /= tl;
6692
- const halfWidth = a.halfWidth + (b.halfWidth - a.halfWidth) * t;
6693
- const across = ((x - cx) * -tz + (z - cz) * tx) / (halfWidth || 1);
6972
+ const signed = (x - cx) * -tz + (z - cz) * tx;
6973
+ const lerp = (u, v) => u + (v - u) * t;
6974
+ const halfWidth = signed < 0 ? lerp(a.halfRight, b.halfRight) : lerp(a.halfLeft, b.halfLeft);
6975
+ const across = signed / (halfWidth || 1);
6694
6976
  return {
6695
6977
  inside: !capped && Math.abs(across) <= 1,
6696
6978
  across,
@@ -6726,7 +7008,7 @@ function detectFalls(rings, opts = {}) {
6726
7008
  topY: lip.surfaceY,
6727
7009
  baseY: base.surfaceY,
6728
7010
  drop,
6729
- halfWidth: base.halfWidth,
7011
+ halfWidth: (base.halfLeft + base.halfRight) / 2,
6730
7012
  dirX: base.tx,
6731
7013
  dirZ: base.tz,
6732
7014
  speed: base.speed
@@ -6930,6 +7212,15 @@ uniform float uRippleStrength;
6930
7212
  uniform float uFlowRate;
6931
7213
  uniform float uSparkle;
6932
7214
  uniform float uFresnelScale;
7215
+ /**
7216
+ * Rocks standing in the current: xz = centre, z = radius, w = how much of the
7217
+ * water they break (0 = drowned and quiet, 1 = the crest stands proud).
7218
+ * The water does not flow AROUND them in any simulated sense — it is told
7219
+ * where they are and paints what a current does when it meets one: a bow
7220
+ * cushion upstream, a broken wake downstream, and a bent surface between.
7221
+ */
7222
+ uniform int uRockCount;
7223
+ uniform vec4 uRocks[8];
6933
7224
 
6934
7225
  varying vec3 vWorldPosition;
6935
7226
  varying vec3 vSurfaceNormal;
@@ -6970,6 +7261,53 @@ void main() {
6970
7261
  float across = vRiver.w;
6971
7262
 
6972
7263
  vec2 dir = normalize(vFlow + vec2(1e-5, 0.0));
7264
+
7265
+ // ── rocks in the current ────────────────────────────────────────────────
7266
+ // Three things happen where a stream meets a boulder, and all three are
7267
+ // read off the same geometry: the flow BENDS around it (a doublet, the
7268
+ // textbook potential flow past a cylinder), it piles into a bow cushion on
7269
+ // the upstream face, and it tears into a broken wake behind. Everything is
7270
+ // in flow space, so a rock in a bend behaves like a rock in a straight.
7271
+ vec2 bend = vec2(0.0);
7272
+ float bow = 0.0;
7273
+ float wake = 0.0;
7274
+ float shed = 0.0;
7275
+ for (int i = 0; i < 8; i++) {
7276
+ if (i >= uRockCount) break;
7277
+ vec4 rock = uRocks[i];
7278
+ float R = max(rock.z, 0.02);
7279
+ vec2 rel = vWorldPosition.xz - rock.xy;
7280
+ float d = length(rel);
7281
+ if (d > R * 6.0) continue;
7282
+ float breaks = rock.w;
7283
+ // flow-space: +s downstream of the rock, t across it
7284
+ float s = dot(rel, dir);
7285
+ float t = dot(rel, vec2(-dir.y, dir.x));
7286
+ // the doublet: strength falls as 1/r², and inside the rock it is capped
7287
+ // so the field never explodes at the centre
7288
+ float rr = max(d, R);
7289
+ // the BEND is not whitewater: even a drowned stone turns the sheen
7290
+ // around itself, so it never fades out with how proud the rock stands
7291
+ bend += (rel / rr) * (R * R / (rr * rr)) * max(breaks, 0.55);
7292
+ // BOW: a cushion of piled water on the upstream face, brightest dead
7293
+ // ahead of the stone and gone by its shoulders
7294
+ float upstream = smoothstep(0.0, -R * 1.1, s);
7295
+ bow += upstream * (1.0 - smoothstep(R * 0.9, R * 2.0, d)) *
7296
+ (1.0 - smoothstep(R * 0.7, R * 1.6, abs(t))) * breaks;
7297
+ // WAKE: a V opening downstream, fading over a few radii
7298
+ float len = R * 5.0;
7299
+ float along = clamp(s / len, 0.0, 1.0);
7300
+ float halfWake = R * (0.5 + 1.3 * along);
7301
+ wake += step(0.0, s) * (1.0 - along) * (1.0 - along) *
7302
+ (1.0 - smoothstep(halfWake * 0.45, halfWake, abs(t))) * breaks;
7303
+ // the SHOULDERS shed water fastest — that is where the white tears off
7304
+ shed += (1.0 - smoothstep(R * 0.8, R * 1.7, d)) *
7305
+ smoothstep(R * 0.35, R * 0.95, abs(t)) * step(-R * 0.6, s) * breaks;
7306
+ }
7307
+ // a bent surface, not a bent texture: rotating the frame the ripple normal
7308
+ // is built in is what makes the sheen sweep around a stone
7309
+ dir = normalize(dir + clamp(bend, vec2(-1.2), vec2(1.2)));
7310
+
6973
7311
  vec3 dir3 = vec3(dir.x, 0.0, dir.y);
6974
7312
  vec3 perp3 = vec3(-dir.y, 0.0, dir.x);
6975
7313
 
@@ -7036,6 +7374,17 @@ void main() {
7036
7374
  float foam = smoothstep(0.74 - 0.45 * drive, 1.02 - 0.24 * drive, streak) * min(drive, 1.0);
7037
7375
  // crest froth: the tops of standing waves in a real rapid always carry white
7038
7376
  foam = max(foam, smoothstep(0.68, 0.96, ripple.x) * smoothstep(0.4, 1.0, rapids) * uFoam * 0.5);
7377
+
7378
+ // rock whitewater: the cushion is a smooth pile (white but soft-edged), the
7379
+ // wake and the shoulders are TORN, so they ride the advected noise and boil
7380
+ // instead of sitting there as a painted decal
7381
+ float boil = 0.5 + 0.5 * sin(uTime * 3.1 + vFlowUv.x * 2.7);
7382
+ float rockFoam = clamp(bow * 1.6, 0.0, 1.0) * (0.62 + 0.38 * boil);
7383
+ rockFoam = max(rockFoam, clamp(wake * 1.5, 0.0, 1.0) * smoothstep(0.24, 0.72, streak) * 1.3);
7384
+ rockFoam = max(rockFoam, clamp(shed * 1.5, 0.0, 1.0) * smoothstep(0.32, 0.8, streak));
7385
+ // a boulder in a millpond barely whitens; the same boulder in a chute tears
7386
+ foam = max(foam, clamp(rockFoam, 0.0, 1.0) * smoothstep(0.15, 0.9, speed) * uFoam);
7387
+
7039
7388
  // froth can never be more present than the water carrying it
7040
7389
  foam = clamp(foam, 0.0, 1.0) * smoothstep(0.02, 0.14, column);
7041
7390
 
@@ -7132,6 +7481,14 @@ const METERS_PER_ACROSS_SEGMENT = 1.5;
7132
7481
  * flow over it, wide enough that terrain grain does not fizz the whole river. */
7133
7482
  const COLUMN_BLUR_PER_HALF_WIDTH = .35;
7134
7483
  const COLUMN_BLUR_MAX = 1.2;
7484
+ /** Rocks the shader can carry at once (the nearest ones win). */
7485
+ const ROCK_SLOTS = 8;
7486
+ /** How often the tree is re-scanned for things standing in the water (s). */
7487
+ const ROCK_RESCAN = .5;
7488
+ /** A rock stops mattering past this many of its own radii from the camera —
7489
+ * never sooner than ROCK_FADE_MIN, or a pebble would blink out at arm's reach. */
7490
+ const ROCK_FADE_RADII = 42;
7491
+ const ROCK_FADE_MIN = 26;
7135
7492
  /** How hard the current drags a body toward the water's own velocity (1/s at
7136
7493
  * `flowForce: 1`). Drag, not thrust: nothing ever outruns the river. Tuned
7137
7494
  * against CharacterController3D, whose stand-still damping fights it — at this
@@ -7150,6 +7507,8 @@ const DEFAULT_COLORS = {
7150
7507
  bank: "#465447"
7151
7508
  };
7152
7509
  const FOAM_COLOR = "#e9f2ef";
7510
+ /** Per-frame scratch (no allocation in the render path). */
7511
+ const rockCamScratch = new Vector3();
7153
7512
  /** Fixed look constants — the props stay intent-level, these carry the craft. */
7154
7513
  const LOOK = {
7155
7514
  /** Pattern units per meter of flow space. */
@@ -7205,6 +7564,7 @@ var River3D = class River3D extends Node3D {
7205
7564
  sunColor: { default: "#fff6e0" },
7206
7565
  sunIntensity: { default: 1 },
7207
7566
  terrain: { default: "" },
7567
+ carve: { default: true },
7208
7568
  flowForce: { default: 1 },
7209
7569
  spray: { default: 1 }
7210
7570
  };
@@ -7240,6 +7600,18 @@ var River3D = class River3D extends Node3D {
7240
7600
  sunIntensity = 1;
7241
7601
  /** Drape target path; empty = auto-find the first Terrain3D in the tree. */
7242
7602
  terrain = "";
7603
+ /**
7604
+ * Cut the bed. Real water arrives on a hillside and erodes itself a channel;
7605
+ * a ribbon laid on raw ground has nothing holding it and reads as a strip of
7606
+ * blue hovering over the grass. With this on, the river trenches its own
7607
+ * course into the terrain it drapes on — banks the player can walk into, a
7608
+ * collider and `heightAt` that agree with it, and splat that paints the cut.
7609
+ *
7610
+ * Off means you carve it yourself with `Terrain3D.channels` (or the reach is
7611
+ * genuinely a flat flood plain). Either way the surface is FITTED to
7612
+ * whatever ground it finds: it can never stand above its own banks.
7613
+ */
7614
+ carve = true;
7243
7615
  /** How hard the current sweeps bodies downstream (0 = visual only). */
7244
7616
  flowForce = 1;
7245
7617
  /**
@@ -7255,6 +7627,11 @@ var River3D = class River3D extends Node3D {
7255
7627
  sprayKey = "";
7256
7628
  courseKey = "";
7257
7629
  bedAt = null;
7630
+ /** Whether the last course was fitted to real ground (see buildRiverRings). */
7631
+ fitted = false;
7632
+ /** Everything standing in the current, rescanned on a slow timer. */
7633
+ rocks = [];
7634
+ rockScanAt = -1;
7258
7635
  world = [
7259
7636
  0,
7260
7637
  0,
@@ -7295,6 +7672,12 @@ var River3D = class River3D extends Node3D {
7295
7672
  _createObject3D() {
7296
7673
  return new Mesh(new BufferGeometry(), this.buildMaterial());
7297
7674
  }
7675
+ onReady() {
7676
+ this.ensureCourse();
7677
+ }
7678
+ onExitTree() {
7679
+ findDrapeTerrain(this, this.terrain)?._setExternalChannel(this, null);
7680
+ }
7298
7681
  update(dt) {
7299
7682
  this.time += dt;
7300
7683
  this.ensureCourse();
@@ -7307,6 +7690,27 @@ var River3D = class River3D extends Node3D {
7307
7690
  this.rebuildIfNeeded(mesh);
7308
7691
  this.syncUniforms(mesh);
7309
7692
  }
7693
+ /** Rocks are picked per FRAME (nearest to the eye), scanned on a timer. */
7694
+ _onRender3D(ctx) {
7695
+ if (this.rings.length === 0) return;
7696
+ if (this.rockScanAt < 0 || this.time - this.rockScanAt > ROCK_RESCAN) {
7697
+ this.rockScanAt = this.time;
7698
+ this.scanRocks();
7699
+ }
7700
+ const u = this._ensureObject3D().material.uniforms;
7701
+ this.syncRocks(u, ctx.camera.getWorldPosition(rockCamScratch));
7702
+ }
7703
+ /**
7704
+ * @internal Water surface height over a world point, or null where this
7705
+ * river does not run — the question scattered vegetation asks before it
7706
+ * plants a blade (see {@link WetSurface}).
7707
+ */
7708
+ _waterYAt(x, z) {
7709
+ if (this.rings.length === 0) return null;
7710
+ const [wx, wy, wz] = this.world;
7711
+ const hit = projectToRiver(this.rings, x - wx, z - wz);
7712
+ return hit?.inside ? hit.surfaceY + wy : null;
7713
+ }
7310
7714
  /**
7311
7715
  * Where a WORLD point sits in the channel — the query gameplay scripts run
7312
7716
  * (steer a raft, drown a torch, decide a ford is too fast to cross). `null`
@@ -7359,6 +7763,31 @@ var River3D = class River3D extends Node3D {
7359
7763
  widthProfile() {
7360
7764
  return this.widths.length > 0 ? this.widths : [this.width];
7361
7765
  }
7766
+ /**
7767
+ * Cut (or release) this river's trench in the terrain it drapes on.
7768
+ *
7769
+ * Runs at ready as well as on every course rebuild: the heightfield collider
7770
+ * is built before the first render sync, so a bed cut only at draw time
7771
+ * would leave the player walking on ground that is no longer there.
7772
+ */
7773
+ applyCarve(terrain) {
7774
+ if (!terrain) return;
7775
+ if (!this.carve || this.path.length < 2) {
7776
+ terrain._setExternalChannel(this, null);
7777
+ return;
7778
+ }
7779
+ terrain._setExternalChannel(this, null);
7780
+ const [wx, , wz] = worldXZY(this);
7781
+ const [tx, , tz] = worldXZY(terrain);
7782
+ const hm = terrain._heightmap();
7783
+ terrain._setExternalChannel(this, riverCarveChannels({
7784
+ path: this.path.map((p) => [(p[0] ?? 0) + wx - tx, (p[1] ?? 0) + wz - tz]),
7785
+ widths: this.widthProfile(),
7786
+ depth: this.depth,
7787
+ cell: (terrain.size[0] ?? 1) / Math.max(1, hm.segsX),
7788
+ groundAt: (x, z) => hm.heightAt(x, z)
7789
+ }));
7790
+ }
7362
7791
  /** Resolve the drape terrain; an explicit path that misses is a hard error. */
7363
7792
  resolveTerrain() {
7364
7793
  const terrain = findDrapeTerrain(this, this.terrain);
@@ -7378,6 +7807,7 @@ var River3D = class River3D extends Node3D {
7378
7807
  this.widths,
7379
7808
  this.depth,
7380
7809
  this.flowSpeed,
7810
+ this.carve,
7381
7811
  terrain ? terrain.name : "",
7382
7812
  world
7383
7813
  ]);
@@ -7385,6 +7815,7 @@ var River3D = class River3D extends Node3D {
7385
7815
  this.courseKey = key;
7386
7816
  this.world = world;
7387
7817
  this.bedAt = null;
7818
+ this.applyCarve(terrain);
7388
7819
  if (this.path.length < 2) {
7389
7820
  this.rings = [];
7390
7821
  this.falls = [];
@@ -7392,6 +7823,7 @@ var River3D = class River3D extends Node3D {
7392
7823
  }
7393
7824
  const [wx, wy, wz] = world;
7394
7825
  this.bedAt = terrain ? (lx, lz) => terrain.heightAt(lx + wx, lz + wz) - wy : () => -this.depth;
7826
+ this.fitted = terrain !== null;
7395
7827
  const widths = this.widthProfile();
7396
7828
  const narrow = Math.min(...widths);
7397
7829
  this.rings = buildRiverRings({
@@ -7400,7 +7832,8 @@ var River3D = class River3D extends Node3D {
7400
7832
  depth: this.depth,
7401
7833
  flowSpeed: this.flowSpeed,
7402
7834
  step: Math.min(Math.max(narrow * STEP_PER_WIDTH, STEP_MIN), STEP_MAX),
7403
- bedAt: this.bedAt
7835
+ bedAt: this.bedAt,
7836
+ fit: this.fitted
7404
7837
  });
7405
7838
  this.falls = detectFalls(this.rings);
7406
7839
  return true;
@@ -7419,7 +7852,7 @@ var River3D = class River3D extends Node3D {
7419
7852
  const wide = Math.max(...widths);
7420
7853
  const data = buildRiverGeometry(this.rings, {
7421
7854
  acrossSegments: Math.min(ACROSS_MAX, Math.max(ACROSS_MIN, Math.round(wide / METERS_PER_ACROSS_SEGMENT))),
7422
- bankOvershoot: BANK_OVERSHOOT,
7855
+ bankOvershoot: this.fitted ? 1 : BANK_OVERSHOOT,
7423
7856
  bedBlur: Math.min(narrow / 2 * COLUMN_BLUR_PER_HALF_WIDTH, COLUMN_BLUR_MAX),
7424
7857
  bedAt: this.bedAt
7425
7858
  });
@@ -7462,6 +7895,8 @@ var River3D = class River3D extends Node3D {
7462
7895
  uRippleStrength: { value: LOOK.rippleStrength * this.ripples },
7463
7896
  uFlowRate: { value: LOOK.flowRate },
7464
7897
  uSparkle: { value: this.ripples },
7898
+ uRockCount: { value: 0 },
7899
+ uRocks: { value: Array.from({ length: ROCK_SLOTS }, () => new Vector4()) },
7465
7900
  uFresnelScale: { value: LOOK.fresnelScale },
7466
7901
  uStandingAmp: { value: LOOK.standingAmp },
7467
7902
  uGlideAmp: { value: LOOK.glideAmp }
@@ -7472,6 +7907,88 @@ var River3D = class River3D extends Node3D {
7472
7907
  side: DoubleSide
7473
7908
  });
7474
7909
  }
7910
+ /**
7911
+ * Find everything standing in the current.
7912
+ *
7913
+ * A boulder in a creek is not a physics problem — it is a thing the water
7914
+ * has to be TOLD about, or the stream runs through it as if it were painted
7915
+ * on. Any mesh whose footprint lands inside the wetted channel and whose top
7916
+ * reaches near the surface counts, `InstancedMesh3D` rows included (a rock
7917
+ * scatter is one node with fifty stones in it).
7918
+ *
7919
+ * Scanned on a slow timer rather than per frame: rocks do not move often,
7920
+ * and a river with a hundred of them still writes only the nearest few.
7921
+ */
7922
+ scanRocks() {
7923
+ const found = [];
7924
+ const [wx, wy, wz] = this.world;
7925
+ /** Keep a candidate if it actually stands IN the water column. */
7926
+ const consider = (x, z, radius, bottom, top) => {
7927
+ if (radius <= .02) return;
7928
+ const hit = projectToRiver(this.rings, x - wx, z - wz);
7929
+ if (!hit || !hit.inside) return;
7930
+ const surface = hit.surfaceY + wy;
7931
+ if (bottom > surface) return;
7932
+ const bed = surface - this.depth;
7933
+ if (top < bed) return;
7934
+ const breaks = Math.min(Math.max((top - bed) / Math.max(this.depth, .05), .2), 1);
7935
+ found.push({
7936
+ x,
7937
+ z,
7938
+ radius,
7939
+ breaks
7940
+ });
7941
+ };
7942
+ let root = this;
7943
+ while (root.parent) root = root.parent;
7944
+ const walk = (node) => {
7945
+ if (node instanceof InstancedMesh3D) {
7946
+ const [sx, sy, sz] = node.size;
7947
+ const [ox, oy, oz] = worldXZY(node);
7948
+ for (const row of node.transforms) {
7949
+ const scale = row[4] ?? 1;
7950
+ const x = ox + (row[0] ?? 0);
7951
+ const y = oy + (row[1] ?? 0);
7952
+ const z = oz + (row[2] ?? 0);
7953
+ const half = (sy ?? 1) * scale / 2;
7954
+ consider(x, z, Math.max(sx, sz) * scale / 2, y - half, y + half);
7955
+ }
7956
+ } else if (node instanceof MeshInstance3D) {
7957
+ const [sx, sy, sz] = node.size;
7958
+ const [x, y, z] = worldXZY(node);
7959
+ const half = (sy ?? 1) / 2;
7960
+ consider(x, z, Math.max(sx, sz) / 2, y - half, y + half);
7961
+ }
7962
+ for (const child of node.children) walk(child);
7963
+ };
7964
+ walk(root);
7965
+ this.rocks = found;
7966
+ }
7967
+ /** Write the nearest rocks into the shader, faded in by distance. */
7968
+ syncRocks(u, camera) {
7969
+ const slots = u.uRocks?.value;
7970
+ const count = u.uRockCount;
7971
+ if (!slots || !count) return;
7972
+ if (this.rocks.length === 0) {
7973
+ count.value = 0;
7974
+ return;
7975
+ }
7976
+ const near = [...this.rocks].map((r) => ({
7977
+ r,
7978
+ d: (r.x - camera.x) ** 2 + (r.z - camera.z) ** 2
7979
+ })).sort((a, b) => a.d - b.d).slice(0, ROCK_SLOTS);
7980
+ let n = 0;
7981
+ for (const { r, d } of near) {
7982
+ const far = Math.max(r.radius * ROCK_FADE_RADII, ROCK_FADE_MIN);
7983
+ const t = Math.min(Math.sqrt(d) / far, 1);
7984
+ const k = Math.min(Math.max((t - .6) / .4, 0), 1);
7985
+ const fade = 1 - k * k * (3 - 2 * k);
7986
+ if (fade <= .01) continue;
7987
+ slots[n]?.set(r.x, r.z, r.radius, r.breaks * fade);
7988
+ n++;
7989
+ }
7990
+ count.value = n;
7991
+ }
7475
7992
  syncUniforms(mesh) {
7476
7993
  const u = mesh.material.uniforms;
7477
7994
  const set = (key, value) => {
@@ -10884,4 +11401,4 @@ function registerNodes3D() {
10884
11401
  registerNode(CharacterBody3D);
10885
11402
  }
10886
11403
  //#endregion
10887
- export { QUARTER_PITCH as A, BoneAttachment3D as C, TERRAIN_THEMES as D, DEFAULT_TERRAIN_TEXTURE_BASE as E, keyboardIntensity as M, movementState as N, terrainThemeLayers as O, rigPose as P, BoneLookAt3D as S, Terrain3D as T, Flowers3D as _, Trail3D as a, CharacterController3D as b, Particles3D as c, DirectionalLight3D as d, OmniLight3D as f, DENSITY_PRESETS as g, Foliage3D as h, Tree3D as i, cameraRelative as j, Joint3D as k, ModelInstance3D as l, MeshInstance3D as m, VOXEL_PALETTE as n, River3D as o, InstancedMesh3D as p, VoxelGrid3D as r, traceDownhillPath as s, registerNodes3D as t, LoftMesh3D as u, resolveFlowerDensity as v, Billboard3D as w, Camera3D as x, FLOWER_VARIETIES as y };
11404
+ export { Joint3D as A, BoneLookAt3D as C, DEFAULT_TERRAIN_TEXTURE_BASE as D, Terrain3D as E, rigPose as F, cameraRelative as M, keyboardIntensity as N, TERRAIN_THEMES as O, movementState as P, Camera3D as S, Billboard3D as T, DENSITY_PRESETS as _, Trail3D as a, FLOWER_VARIETIES as b, traceDownhillPath as c, LoftMesh3D as d, DirectionalLight3D as f, Foliage3D as g, MeshInstance3D as h, Tree3D as i, QUARTER_PITCH as j, terrainThemeLayers as k, Particles3D as l, InstancedMesh3D as m, VOXEL_PALETTE as n, River3D as o, OmniLight3D as p, VoxelGrid3D as r, riverCarveChannels as s, registerNodes3D as t, ModelInstance3D as u, Flowers3D as v, BoneAttachment3D as w, CharacterController3D as x, resolveFlowerDensity as y };