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.
- package/dist/2d.js +1 -1
- package/dist/3d.d.ts +248 -135
- package/dist/3d.js +5 -5
- package/dist/{create-game-C0bPoURs.js → create-game-71TJjW1T.js} +1 -1
- package/dist/{create-game-B_qBR65J.js → create-game-BRgWpNsa.js} +3 -3
- package/dist/env.d.ts +6 -0
- package/dist/env.js +8 -5
- package/dist/{gameplay-Ddk13pQ6.js → gameplay-DEG-TP7D.js} +13 -0
- package/dist/gameplay.js +1 -1
- package/dist/index.js +1 -1
- package/dist/{physics-3d-Cd7Jl8w1.js → physics-3d--y5clE2j.js} +2 -2
- package/dist/react.js +1 -1
- package/dist/{register-CeWtdiCw.js → register-6R75AC7-.js} +544 -27
- package/dist/test.js +4 -4
- package/editor/assets/{agent8-DW6yTjTv.js → agent8-CojUfCXN.js} +1 -1
- package/editor/assets/{index-D4_I76v2.js → index-D6RQgROR.js} +119 -52
- package/editor/index.html +1 -1
- package/package.json +1 -1
- package/schemas/scene.schema.json +12 -0
- package/skills/incanto-environment.md +73 -9
- package/skills/incanto-node-reference.md +3 -0
- package/templates-app/beacon-isle-3d/package.json +1 -1
- package/templates-app/tps-3d/package.json +1 -1
- package/templates-app/village-quest-3d/package.json +1 -1
|
@@ -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-
|
|
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
|
-
/**
|
|
902
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
4787
|
-
|
|
4788
|
-
|
|
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(
|
|
4791
|
-
mesh.setColorAt(
|
|
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 =
|
|
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 = (
|
|
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
|
-
/**
|
|
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.
|
|
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] =
|
|
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,
|
|
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
|
|
6693
|
-
const
|
|
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.
|
|
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 {
|
|
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 };
|