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