incanto 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/3d.d.ts +211 -1
- package/dist/3d.js +5 -5
- package/dist/{create-game-Ct6RC4LF.js → create-game-pNf3sI-Q.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-DxFDfUey.js} +1 -1
- package/dist/react.js +1 -1
- package/dist/{register-Cv5GNiVo.js → register-BWFgsGra.js} +1290 -125
- package/dist/test.js +3 -3
- package/editor/assets/{agent8-CcfBcssq.js → agent8-BZX7dF4f.js} +1 -1
- package/editor/assets/index-CXdpxO_B.js +7928 -0
- package/editor/index.html +1 -1
- package/package.json +1 -1
- package/schemas/scene.schema.json +171 -0
- package/skills/incanto-environment.md +122 -1
- package/skills/incanto-node-reference.md +29 -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
- package/editor/assets/index-B7gj1VII.js +0 -7638
|
@@ -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,1161 @@ function softSprite() {
|
|
|
6672
6684
|
return tex;
|
|
6673
6685
|
}
|
|
6674
6686
|
//#endregion
|
|
6687
|
+
//#region src/3d/water/interaction.ts
|
|
6688
|
+
/**
|
|
6689
|
+
* Water3D character interaction — pure functions, no three, node-env testable.
|
|
6690
|
+
*
|
|
6691
|
+
* The node samples physics bodies each update, detects waterline crossings
|
|
6692
|
+
* (`detectCrossings`), and converts them into ripple impulses in a FIXED-SIZE
|
|
6693
|
+
* ring buffer that mirrors the shader's `uRipples[8]` uniform array
|
|
6694
|
+
* (`pushRipple` / `ageRipples`). `rippleHeight` is the exact ring-wave shape
|
|
6695
|
+
* the vertex shader evaluates — `quality: 'simple'` runs it on the CPU.
|
|
6696
|
+
*/
|
|
6697
|
+
/** Ring-buffer capacity — MUST match `uRipples[8]` in the water shaders. */
|
|
6698
|
+
const WATER_MAX_RIPPLES = 8;
|
|
6699
|
+
/**
|
|
6700
|
+
* Distance from a body's ORIGIN down to its collider's lowest point. Water
|
|
6701
|
+
* interaction adds this to the waterline so a body counts as touching the water
|
|
6702
|
+
* when its FEET dip below the surface — not only when its centre does (a wading
|
|
6703
|
+
* character whose origin rides ~1 m above its feet would otherwise never splash).
|
|
6704
|
+
* 0 for a missing/empty/unsized collider (origin == feet).
|
|
6705
|
+
*/
|
|
6706
|
+
function colliderFootDrop(collider) {
|
|
6707
|
+
if (!collider || typeof collider !== "object") return 0;
|
|
6708
|
+
switch (collider.shape) {
|
|
6709
|
+
case "capsule": return (collider.height ?? 0) / 2 + (collider.radius ?? 0);
|
|
6710
|
+
case "sphere": return collider.radius ?? 0;
|
|
6711
|
+
case "box": return (collider.size?.[1] ?? 0) / 2;
|
|
6712
|
+
default: return 0;
|
|
6713
|
+
}
|
|
6714
|
+
}
|
|
6715
|
+
/**
|
|
6716
|
+
* Foam strength (0..1) for a body ENTERING the water, from its DOWNWARD speed:
|
|
6717
|
+
* a gentle wade-in froths a little; a hard plunge froths fully. Clamped at
|
|
6718
|
+
* `FOAM_FULL_SPEED`.
|
|
6719
|
+
*/
|
|
6720
|
+
function entryFoam(fallSpeed) {
|
|
6721
|
+
return .55 + .45 * (Math.min(Math.max(fallSpeed, 0), 12) / 12);
|
|
6722
|
+
}
|
|
6723
|
+
/**
|
|
6724
|
+
* Diff body positions against the waterline. A body enters when `y` drops
|
|
6725
|
+
* below its waterline and exits when it rises above `waterline + hysteresis`
|
|
6726
|
+
* (the band stops surface-bobbing bodies from flickering entered/exited).
|
|
6727
|
+
* Bodies missing from `samples` (despawned mid-water) are dropped WITHOUT an
|
|
6728
|
+
* `exited` event — their splash position no longer exists.
|
|
6729
|
+
*/
|
|
6730
|
+
function detectCrossings(prev, samples, hysteresis = 0) {
|
|
6731
|
+
const entered = [];
|
|
6732
|
+
const exited = [];
|
|
6733
|
+
const inWater = /* @__PURE__ */ new Set();
|
|
6734
|
+
for (const s of samples) {
|
|
6735
|
+
const wasIn = prev.has(s.id);
|
|
6736
|
+
if (wasIn ? s.y <= s.waterline + hysteresis : s.y < s.waterline) {
|
|
6737
|
+
inWater.add(s.id);
|
|
6738
|
+
if (!wasIn) entered.push(s);
|
|
6739
|
+
} else if (wasIn) exited.push(s);
|
|
6740
|
+
}
|
|
6741
|
+
return {
|
|
6742
|
+
entered,
|
|
6743
|
+
exited,
|
|
6744
|
+
inWater
|
|
6745
|
+
};
|
|
6746
|
+
}
|
|
6747
|
+
/** Append a ripple at age 0; when the buffer is full, evict the OLDEST.
|
|
6748
|
+
* `foam` (0..1) tags how much surface froth the shaders paint around it. */
|
|
6749
|
+
function pushRipple(ripples, x, z, amp, foam = 0, max = 8) {
|
|
6750
|
+
if (ripples.length >= max) {
|
|
6751
|
+
let oldest = 0;
|
|
6752
|
+
for (let i = 1; i < ripples.length; i++) if (ripples[i].age > ripples[oldest].age) oldest = i;
|
|
6753
|
+
ripples.splice(oldest, 1);
|
|
6754
|
+
}
|
|
6755
|
+
ripples.push({
|
|
6756
|
+
x,
|
|
6757
|
+
z,
|
|
6758
|
+
age: 0,
|
|
6759
|
+
amp,
|
|
6760
|
+
foam
|
|
6761
|
+
});
|
|
6762
|
+
}
|
|
6763
|
+
/** Advance ages by `dt` and drop ripples decayed past `maxAge` (in place). */
|
|
6764
|
+
function ageRipples(ripples, dt, maxAge = 3) {
|
|
6765
|
+
for (let i = ripples.length - 1; i >= 0; i--) {
|
|
6766
|
+
const r = ripples[i];
|
|
6767
|
+
r.age += dt;
|
|
6768
|
+
if (r.age >= maxAge) ripples.splice(i, 1);
|
|
6769
|
+
}
|
|
6770
|
+
}
|
|
6771
|
+
/** The ring-wave height contribution of one ripple at world (x, z). */
|
|
6772
|
+
function rippleHeight(x, z, ripple) {
|
|
6773
|
+
const dx = x - ripple.x;
|
|
6774
|
+
const dz = z - ripple.z;
|
|
6775
|
+
const r = Math.sqrt(dx * dx + dz * dz);
|
|
6776
|
+
return ripple.amp * Math.sin(6 * r - 8 * ripple.age) * Math.exp(-1.1 * ripple.age) * Math.exp(-.15 * r * r);
|
|
6777
|
+
}
|
|
6778
|
+
/** Sum of every live ripple's height at world (x, z) — the CPU water path. */
|
|
6779
|
+
function ripplesHeightAt(x, z, ripples) {
|
|
6780
|
+
let h = 0;
|
|
6781
|
+
for (const r of ripples) h += rippleHeight(x, z, r);
|
|
6782
|
+
return h;
|
|
6783
|
+
}
|
|
6784
|
+
/**
|
|
6785
|
+
* Accumulate per-body bob timers for bodies floating IN water; returns the
|
|
6786
|
+
* ids due for a gentle bob impulse this update (timer wraps, cadence keeps).
|
|
6787
|
+
* Timers of bodies that left the water are forgotten.
|
|
6788
|
+
*/
|
|
6789
|
+
function stepBobTimers(timers, inWater, dt, interval) {
|
|
6790
|
+
const due = [];
|
|
6791
|
+
for (const id of timers.keys()) if (!inWater.has(id)) timers.delete(id);
|
|
6792
|
+
for (const id of inWater) {
|
|
6793
|
+
const t = (timers.get(id) ?? 0) + dt;
|
|
6794
|
+
if (t >= interval) {
|
|
6795
|
+
due.push(id);
|
|
6796
|
+
timers.set(id, t - interval);
|
|
6797
|
+
} else timers.set(id, t);
|
|
6798
|
+
}
|
|
6799
|
+
return due;
|
|
6800
|
+
}
|
|
6801
|
+
//#endregion
|
|
6802
|
+
//#region src/3d/water/river.ts
|
|
6803
|
+
/** Dense Catmull-Rom taps per control segment before arc-length resampling. */
|
|
6804
|
+
const SPLINE_TAPS = 24;
|
|
6805
|
+
/** Box-filter half-window (samples) that irons kinks out of the bed profile. */
|
|
6806
|
+
const PROFILE_SMOOTH = 3;
|
|
6807
|
+
/** Grade → current gain: a 1:10 chute runs ~1.4× the reference speed. */
|
|
6808
|
+
const SLOPE_GAIN = 4;
|
|
6809
|
+
const SPEED_MIN = .05;
|
|
6810
|
+
const SPEED_MAX = 12;
|
|
6811
|
+
/**
|
|
6812
|
+
* Smooth the control path and walk it at even arc-length steps. The step is
|
|
6813
|
+
* nudged so the last sample lands exactly on the mouth — an odd short segment
|
|
6814
|
+
* at the end would pinch the ribbon's final quad.
|
|
6815
|
+
*/
|
|
6816
|
+
function resamplePath(path, step) {
|
|
6817
|
+
const pts = path.map((p) => ({
|
|
6818
|
+
x: p[0] ?? 0,
|
|
6819
|
+
z: p[1] ?? 0
|
|
6820
|
+
})).filter((p, i, all) => i === 0 || Math.hypot(p.x - (all[i - 1]?.x ?? 0), p.z - (all[i - 1]?.z ?? 0)) > 1e-6);
|
|
6821
|
+
if (pts.length < 2 || step <= 0) return [];
|
|
6822
|
+
const dense = [];
|
|
6823
|
+
let acc = 0;
|
|
6824
|
+
for (let i = 0; i < pts.length - 1; i++) {
|
|
6825
|
+
const p0 = pts[Math.max(0, i - 1)];
|
|
6826
|
+
const p1 = pts[i];
|
|
6827
|
+
const p2 = pts[i + 1];
|
|
6828
|
+
const p3 = pts[Math.min(pts.length - 1, i + 2)];
|
|
6829
|
+
for (let s = 0; s < SPLINE_TAPS; s++) {
|
|
6830
|
+
const t = s / SPLINE_TAPS;
|
|
6831
|
+
const x = catmullRom(p0.x, p1.x, p2.x, p3.x, t);
|
|
6832
|
+
const z = catmullRom(p0.z, p1.z, p2.z, p3.z, t);
|
|
6833
|
+
const prev = dense[dense.length - 1];
|
|
6834
|
+
if (prev) acc += Math.hypot(x - prev.x, z - prev.z);
|
|
6835
|
+
dense.push({
|
|
6836
|
+
x,
|
|
6837
|
+
z,
|
|
6838
|
+
along: acc
|
|
6839
|
+
});
|
|
6840
|
+
}
|
|
6841
|
+
}
|
|
6842
|
+
const tail = pts[pts.length - 1];
|
|
6843
|
+
const prev = dense[dense.length - 1];
|
|
6844
|
+
if (prev) acc += Math.hypot(tail.x - prev.x, tail.z - prev.z);
|
|
6845
|
+
dense.push({
|
|
6846
|
+
x: tail.x,
|
|
6847
|
+
z: tail.z,
|
|
6848
|
+
along: acc
|
|
6849
|
+
});
|
|
6850
|
+
const total = acc;
|
|
6851
|
+
if (total < 1e-6) return [];
|
|
6852
|
+
const spans = Math.max(1, Math.round(total / step));
|
|
6853
|
+
const even = total / spans;
|
|
6854
|
+
const out = [];
|
|
6855
|
+
let cursor = 0;
|
|
6856
|
+
for (let i = 0; i <= spans; i++) {
|
|
6857
|
+
const target = Math.min(i * even, total);
|
|
6858
|
+
while (cursor < dense.length - 2 && dense[cursor + 1].along < target) cursor++;
|
|
6859
|
+
const a = dense[cursor];
|
|
6860
|
+
const b = dense[Math.min(cursor + 1, dense.length - 1)];
|
|
6861
|
+
const span = b.along - a.along;
|
|
6862
|
+
const t = span > 1e-9 ? (target - a.along) / span : 0;
|
|
6863
|
+
out.push({
|
|
6864
|
+
x: a.x + (b.x - a.x) * t,
|
|
6865
|
+
z: a.z + (b.z - a.z) * t,
|
|
6866
|
+
tx: 1,
|
|
6867
|
+
tz: 0,
|
|
6868
|
+
along: target
|
|
6869
|
+
});
|
|
6870
|
+
}
|
|
6871
|
+
for (let i = 0; i < out.length; i++) {
|
|
6872
|
+
const a = out[Math.max(0, i - 1)];
|
|
6873
|
+
const b = out[Math.min(out.length - 1, i + 1)];
|
|
6874
|
+
const dx = b.x - a.x;
|
|
6875
|
+
const dz = b.z - a.z;
|
|
6876
|
+
const len = Math.hypot(dx, dz) || 1;
|
|
6877
|
+
const cur = out[i];
|
|
6878
|
+
cur.tx = dx / len;
|
|
6879
|
+
cur.tz = dz / len;
|
|
6880
|
+
}
|
|
6881
|
+
return out;
|
|
6882
|
+
}
|
|
6883
|
+
/** Width profile lookup at normalized distance `t` ∈ [0, 1] down the river. */
|
|
6884
|
+
function widthAlong(widths, t) {
|
|
6885
|
+
if (widths.length === 0) return 0;
|
|
6886
|
+
if (widths.length === 1) return widths[0];
|
|
6887
|
+
const pos = Math.min(Math.max(t, 0), 1) * (widths.length - 1);
|
|
6888
|
+
const i = Math.min(Math.floor(pos), widths.length - 2);
|
|
6889
|
+
const f = pos - i;
|
|
6890
|
+
const a = widths[i];
|
|
6891
|
+
return a + (widths[i + 1] - a) * f;
|
|
6892
|
+
}
|
|
6893
|
+
/**
|
|
6894
|
+
* Stations along the river: surface height, grade and current.
|
|
6895
|
+
*
|
|
6896
|
+
* The surface profile is the bed's DESCENDING envelope (a running minimum),
|
|
6897
|
+
* box-smoothed and re-clamped — water can pour over a lip but never climb one,
|
|
6898
|
+
* and the smoothing keeps a noisy bed from shattering the surface into steps.
|
|
6899
|
+
* Bed bumps that poke above the resulting surface simply lose their water
|
|
6900
|
+
* column: the shader drops them out as riffle rocks.
|
|
6901
|
+
*/
|
|
6902
|
+
function buildRiverRings(opts) {
|
|
6903
|
+
const samples = resamplePath(opts.path, opts.step);
|
|
6904
|
+
if (samples.length === 0) return [];
|
|
6905
|
+
const total = samples[samples.length - 1].along || 1;
|
|
6906
|
+
const halfWidths = samples.map((s) => Math.max(widthAlong(opts.widths, s.along / total), .05) / 2);
|
|
6907
|
+
const bed = samples.map((s) => opts.bedAt(s.x, s.z));
|
|
6908
|
+
const descending = new Array(bed.length);
|
|
6909
|
+
for (let i = 0; i < bed.length; i++) {
|
|
6910
|
+
const raw = bed[i];
|
|
6911
|
+
descending[i] = i === 0 ? raw : Math.min(raw, descending[i - 1]);
|
|
6912
|
+
}
|
|
6913
|
+
const smoothed = descending.map((_, i) => {
|
|
6914
|
+
let sum = 0;
|
|
6915
|
+
let n = 0;
|
|
6916
|
+
for (let k = -3; k <= PROFILE_SMOOTH; k++) {
|
|
6917
|
+
const j = Math.min(Math.max(i + k, 0), descending.length - 1);
|
|
6918
|
+
sum += descending[j];
|
|
6919
|
+
n++;
|
|
6920
|
+
}
|
|
6921
|
+
return sum / n;
|
|
6922
|
+
});
|
|
6923
|
+
const profile = new Array(smoothed.length);
|
|
6924
|
+
for (let i = 0; i < smoothed.length; i++) {
|
|
6925
|
+
const s = smoothed[i];
|
|
6926
|
+
profile[i] = i === 0 ? s : Math.min(s, profile[i - 1]);
|
|
6927
|
+
}
|
|
6928
|
+
const meanHalf = halfWidths.reduce((a, b) => a + b, 0) / halfWidths.length || 1;
|
|
6929
|
+
return samples.map((s, i) => {
|
|
6930
|
+
const surfaceY = profile[i] + opts.depth;
|
|
6931
|
+
const prev = Math.max(0, i - 1);
|
|
6932
|
+
const next = Math.min(samples.length - 1, i + 1);
|
|
6933
|
+
const run = samples[next].along - samples[prev].along;
|
|
6934
|
+
const drop = (profile[prev] + opts.depth - (profile[next] + opts.depth)) / (run || 1);
|
|
6935
|
+
const slope = Math.max(0, drop);
|
|
6936
|
+
const halfWidth = halfWidths[i];
|
|
6937
|
+
const speed = Math.min(Math.max(opts.flowSpeed * Math.sqrt(meanHalf / halfWidth) * (1 + SLOPE_GAIN * slope), SPEED_MIN), SPEED_MAX);
|
|
6938
|
+
return {
|
|
6939
|
+
...s,
|
|
6940
|
+
halfWidth,
|
|
6941
|
+
surfaceY,
|
|
6942
|
+
slope,
|
|
6943
|
+
speed
|
|
6944
|
+
};
|
|
6945
|
+
});
|
|
6946
|
+
}
|
|
6947
|
+
/** Ribbon mesh for the stations — flat across, draped along, attribute-rich. */
|
|
6948
|
+
function buildRiverGeometry(rings, opts) {
|
|
6949
|
+
const across = Math.max(1, Math.round(opts.acrossSegments));
|
|
6950
|
+
const perRing = across + 1;
|
|
6951
|
+
const count = rings.length * perRing;
|
|
6952
|
+
const positions = new Float32Array(count * 3);
|
|
6953
|
+
const normals = new Float32Array(count * 3);
|
|
6954
|
+
const uvs = new Float32Array(count * 2);
|
|
6955
|
+
const flow = new Float32Array(count * 2);
|
|
6956
|
+
const river = new Float32Array(count * 4);
|
|
6957
|
+
const quads = Math.max(0, rings.length - 1) * across;
|
|
6958
|
+
const indices = new Uint32Array(quads * 6);
|
|
6959
|
+
for (let r = 0; r < rings.length; r++) {
|
|
6960
|
+
const ring = rings[r];
|
|
6961
|
+
const px = -ring.tz;
|
|
6962
|
+
const pz = ring.tx;
|
|
6963
|
+
const nLen = Math.hypot(ring.slope, 1);
|
|
6964
|
+
const nx = ring.tx * ring.slope / nLen;
|
|
6965
|
+
const ny = 1 / nLen;
|
|
6966
|
+
const nz = ring.tz * ring.slope / nLen;
|
|
6967
|
+
for (let c = 0; c <= across; c++) {
|
|
6968
|
+
const t = c / across * 2 - 1;
|
|
6969
|
+
const offset = t * ring.halfWidth * opts.bankOvershoot;
|
|
6970
|
+
const x = ring.x + px * offset;
|
|
6971
|
+
const z = ring.z + pz * offset;
|
|
6972
|
+
const i = r * perRing + c;
|
|
6973
|
+
positions[i * 3] = x;
|
|
6974
|
+
positions[i * 3 + 1] = ring.surfaceY;
|
|
6975
|
+
positions[i * 3 + 2] = z;
|
|
6976
|
+
normals[i * 3] = nx;
|
|
6977
|
+
normals[i * 3 + 1] = ny;
|
|
6978
|
+
normals[i * 3 + 2] = nz;
|
|
6979
|
+
uvs[i * 2] = ring.along;
|
|
6980
|
+
uvs[i * 2 + 1] = offset;
|
|
6981
|
+
flow[i * 2] = ring.tx;
|
|
6982
|
+
flow[i * 2 + 1] = ring.tz;
|
|
6983
|
+
river[i * 4] = ring.speed;
|
|
6984
|
+
river[i * 4 + 1] = Math.max(0, ring.surfaceY - sampleBed(opts, x, z));
|
|
6985
|
+
river[i * 4 + 2] = ring.slope;
|
|
6986
|
+
river[i * 4 + 3] = t * opts.bankOvershoot;
|
|
6987
|
+
}
|
|
6988
|
+
}
|
|
6989
|
+
let w = 0;
|
|
6990
|
+
for (let r = 0; r + 1 < rings.length; r++) for (let c = 0; c < across; c++) {
|
|
6991
|
+
const a = r * perRing + c;
|
|
6992
|
+
const b = a + 1;
|
|
6993
|
+
const d = a + perRing;
|
|
6994
|
+
const e = d + 1;
|
|
6995
|
+
indices[w++] = a;
|
|
6996
|
+
indices[w++] = b;
|
|
6997
|
+
indices[w++] = d;
|
|
6998
|
+
indices[w++] = b;
|
|
6999
|
+
indices[w++] = e;
|
|
7000
|
+
indices[w++] = d;
|
|
7001
|
+
}
|
|
7002
|
+
return {
|
|
7003
|
+
positions,
|
|
7004
|
+
normals,
|
|
7005
|
+
uvs,
|
|
7006
|
+
flow,
|
|
7007
|
+
river,
|
|
7008
|
+
indices
|
|
7009
|
+
};
|
|
7010
|
+
}
|
|
7011
|
+
/**
|
|
7012
|
+
* Where a world point sits in the channel — the query gameplay runs against
|
|
7013
|
+
* (drift a boat, sweep a swimmer, splash a footstep). Projects onto the two
|
|
7014
|
+
* segments neighbouring the closest station and keeps the better one.
|
|
7015
|
+
*/
|
|
7016
|
+
/** Bed height under a vertex, averaged over `bedBlur` (0 = a single tap). */
|
|
7017
|
+
function sampleBed(opts, x, z) {
|
|
7018
|
+
const r = opts.bedBlur ?? 0;
|
|
7019
|
+
if (r <= 0) return opts.bedAt(x, z);
|
|
7020
|
+
return (opts.bedAt(x, z) + opts.bedAt(x + r, z) + opts.bedAt(x - r, z) + opts.bedAt(x, z + r) + opts.bedAt(x, z - r)) / 5;
|
|
7021
|
+
}
|
|
7022
|
+
function projectToRiver(rings, x, z) {
|
|
7023
|
+
if (rings.length === 0) return null;
|
|
7024
|
+
let best = 0;
|
|
7025
|
+
let bestD = Number.POSITIVE_INFINITY;
|
|
7026
|
+
for (let i = 0; i < rings.length; i++) {
|
|
7027
|
+
const r = rings[i];
|
|
7028
|
+
const d = (r.x - x) ** 2 + (r.z - z) ** 2;
|
|
7029
|
+
if (d < bestD) {
|
|
7030
|
+
bestD = d;
|
|
7031
|
+
best = i;
|
|
7032
|
+
}
|
|
7033
|
+
}
|
|
7034
|
+
if (rings.length === 1) {
|
|
7035
|
+
const only = rings[0];
|
|
7036
|
+
return hitFrom(only, only, 0, x, z, false);
|
|
7037
|
+
}
|
|
7038
|
+
let hit = null;
|
|
7039
|
+
let hitD = Number.POSITIVE_INFINITY;
|
|
7040
|
+
for (const start of [best - 1, best]) {
|
|
7041
|
+
if (start < 0 || start + 1 >= rings.length) continue;
|
|
7042
|
+
const a = rings[start];
|
|
7043
|
+
const b = rings[start + 1];
|
|
7044
|
+
const dx = b.x - a.x;
|
|
7045
|
+
const dz = b.z - a.z;
|
|
7046
|
+
const len2 = dx * dx + dz * dz;
|
|
7047
|
+
const raw = len2 > 1e-9 ? ((x - a.x) * dx + (z - a.z) * dz) / len2 : 0;
|
|
7048
|
+
const t = Math.min(Math.max(raw, 0), 1);
|
|
7049
|
+
const capped = start === 0 && raw < 0 || start + 2 === rings.length && raw > 1;
|
|
7050
|
+
const px = a.x + dx * t;
|
|
7051
|
+
const pz = a.z + dz * t;
|
|
7052
|
+
const d = (px - x) ** 2 + (pz - z) ** 2;
|
|
7053
|
+
if (d < hitD) {
|
|
7054
|
+
hitD = d;
|
|
7055
|
+
hit = hitFrom(a, b, t, x, z, capped);
|
|
7056
|
+
}
|
|
7057
|
+
}
|
|
7058
|
+
return hit;
|
|
7059
|
+
}
|
|
7060
|
+
function hitFrom(a, b, t, x, z, capped) {
|
|
7061
|
+
const cx = a.x + (b.x - a.x) * t;
|
|
7062
|
+
const cz = a.z + (b.z - a.z) * t;
|
|
7063
|
+
let tx = a.tx + (b.tx - a.tx) * t;
|
|
7064
|
+
let tz = a.tz + (b.tz - a.tz) * t;
|
|
7065
|
+
const tl = Math.hypot(tx, tz) || 1;
|
|
7066
|
+
tx /= tl;
|
|
7067
|
+
tz /= tl;
|
|
7068
|
+
const halfWidth = a.halfWidth + (b.halfWidth - a.halfWidth) * t;
|
|
7069
|
+
const across = ((x - cx) * -tz + (z - cz) * tx) / (halfWidth || 1);
|
|
7070
|
+
return {
|
|
7071
|
+
inside: !capped && Math.abs(across) <= 1,
|
|
7072
|
+
across,
|
|
7073
|
+
along: a.along + (b.along - a.along) * t,
|
|
7074
|
+
surfaceY: a.surfaceY + (b.surfaceY - a.surfaceY) * t,
|
|
7075
|
+
dirX: tx,
|
|
7076
|
+
dirZ: tz,
|
|
7077
|
+
speed: a.speed + (b.speed - a.speed) * t,
|
|
7078
|
+
halfWidth
|
|
7079
|
+
};
|
|
7080
|
+
}
|
|
7081
|
+
/**
|
|
7082
|
+
* Trace where water would actually run from a point: repeated steepest descent
|
|
7083
|
+
* with momentum, so the line follows a valley floor instead of rattling between
|
|
7084
|
+
* its walls. Ends when the ground goes flat (a pool), the trace leaves the play
|
|
7085
|
+
* area, or `maxPoints` is reached.
|
|
7086
|
+
*
|
|
7087
|
+
* Feed the result straight into a {@link River3D} `path` — the terrain then
|
|
7088
|
+
* picks its own river, which is how a generated map gets water that belongs.
|
|
7089
|
+
*/
|
|
7090
|
+
function traceDownhillPath(bedAt, opts) {
|
|
7091
|
+
const step = Math.max(opts.step, .001);
|
|
7092
|
+
const maxPoints = Math.max(2, opts.maxPoints ?? 200);
|
|
7093
|
+
const minDrop = opts.minDrop ?? step * .01;
|
|
7094
|
+
const bounds = opts.bounds ?? Number.POSITIVE_INFINITY;
|
|
7095
|
+
let x = opts.x;
|
|
7096
|
+
let z = opts.z;
|
|
7097
|
+
let dirX = 0;
|
|
7098
|
+
let dirZ = 0;
|
|
7099
|
+
let stalls = 0;
|
|
7100
|
+
const out = [[x, z]];
|
|
7101
|
+
for (let i = 1; i < maxPoints; i++) {
|
|
7102
|
+
const gx = (bedAt(x + step, z) - bedAt(x - step, z)) / (2 * step);
|
|
7103
|
+
const gz = (bedAt(x, z + step) - bedAt(x, z - step)) / (2 * step);
|
|
7104
|
+
const grad = Math.hypot(gx, gz);
|
|
7105
|
+
let nx = grad > 1e-9 ? -gx / grad : dirX;
|
|
7106
|
+
let nz = grad > 1e-9 ? -gz / grad : dirZ;
|
|
7107
|
+
nx = nx * .6 + dirX * .4;
|
|
7108
|
+
nz = nz * .6 + dirZ * .4;
|
|
7109
|
+
const len = Math.hypot(nx, nz);
|
|
7110
|
+
if (len < 1e-6) break;
|
|
7111
|
+
dirX = nx / len;
|
|
7112
|
+
dirZ = nz / len;
|
|
7113
|
+
let px = x + dirX * step;
|
|
7114
|
+
let pz = z + dirZ * step;
|
|
7115
|
+
let bestOff = 0;
|
|
7116
|
+
let bestY = bedAt(px, pz);
|
|
7117
|
+
for (const off of [
|
|
7118
|
+
-.8,
|
|
7119
|
+
-.4,
|
|
7120
|
+
.4,
|
|
7121
|
+
.8
|
|
7122
|
+
]) {
|
|
7123
|
+
const y = bedAt(px - dirZ * off * step, pz + dirX * off * step);
|
|
7124
|
+
if (y < bestY - 1e-4) {
|
|
7125
|
+
bestY = y;
|
|
7126
|
+
bestOff = off;
|
|
7127
|
+
}
|
|
7128
|
+
}
|
|
7129
|
+
if (bestOff !== 0) {
|
|
7130
|
+
px -= dirZ * bestOff * step;
|
|
7131
|
+
pz += dirX * bestOff * step;
|
|
7132
|
+
}
|
|
7133
|
+
if (Math.abs(px) > bounds || Math.abs(pz) > bounds) break;
|
|
7134
|
+
if (bedAt(x, z) - bedAt(px, pz) < minDrop) {
|
|
7135
|
+
if (++stalls >= 3) break;
|
|
7136
|
+
} else stalls = 0;
|
|
7137
|
+
x = px;
|
|
7138
|
+
z = pz;
|
|
7139
|
+
out.push([x, z]);
|
|
7140
|
+
}
|
|
7141
|
+
return out.length >= 2 ? out : [];
|
|
7142
|
+
}
|
|
7143
|
+
function catmullRom(p0, p1, p2, p3, t) {
|
|
7144
|
+
const t2 = t * t;
|
|
7145
|
+
const t3 = t2 * t;
|
|
7146
|
+
return .5 * (2 * p1 + (-p0 + p2) * t + (2 * p0 - 5 * p1 + 4 * p2 - p3) * t2 + (-p0 + 3 * p1 - 3 * p2 + p3) * t3);
|
|
7147
|
+
}
|
|
7148
|
+
//#endregion
|
|
7149
|
+
//#region src/3d/water/river-shaders.ts
|
|
7150
|
+
/**
|
|
7151
|
+
* River surface shader — running water, as distinct from the standing water of
|
|
7152
|
+
* {@link WATER_FRAG}.
|
|
7153
|
+
*
|
|
7154
|
+
* The whole look hangs off four per-vertex facts the CPU derives from the path
|
|
7155
|
+
* and the ground (see river.ts): the downstream direction, the current speed,
|
|
7156
|
+
* the water column and the grade. From those:
|
|
7157
|
+
*
|
|
7158
|
+
* - **the current is a frame, not a scroll.** Every pattern lives in the
|
|
7159
|
+
* ribbon's (downstream, across) meters, so a bend carries its ripples around
|
|
7160
|
+
* the corner instead of dragging a world-locked texture sideways.
|
|
7161
|
+
* - **speed varies along a river, and that shears any scrolling field apart.**
|
|
7162
|
+
* Two copies of every advected pattern run half a period out of step and
|
|
7163
|
+
* cross-fade on a triangle wave, so each is reset while the other carries the
|
|
7164
|
+
* image — the surface never accumulates smear.
|
|
7165
|
+
* - **whitewater is derived, never painted.** Grade × speed makes rapids, the
|
|
7166
|
+
* bank shear line makes edge froth, a thinning column makes riffles over
|
|
7167
|
+
* rock. Foam then rides downstream as stretched streaks.
|
|
7168
|
+
* - **standing waves stand.** In a chute the humps are world-locked (phase on
|
|
7169
|
+
* the downstream coordinate, no scroll) while the water tears through them —
|
|
7170
|
+
* the signature of a hydraulic jump, and the thing that reads as "fast".
|
|
7171
|
+
*/
|
|
7172
|
+
const RIVER_NOISE = `
|
|
7173
|
+
float riverHash(vec2 p) {
|
|
7174
|
+
p = fract(p * vec2(127.31, 311.7));
|
|
7175
|
+
p += dot(p, p + 34.23);
|
|
7176
|
+
return fract(p.x * p.y);
|
|
7177
|
+
}
|
|
7178
|
+
|
|
7179
|
+
float riverNoise(vec2 p) {
|
|
7180
|
+
vec2 i = floor(p);
|
|
7181
|
+
vec2 f = fract(p);
|
|
7182
|
+
vec2 u = f * f * (3.0 - 2.0 * f);
|
|
7183
|
+
float a = riverHash(i);
|
|
7184
|
+
float b = riverHash(i + vec2(1.0, 0.0));
|
|
7185
|
+
float c = riverHash(i + vec2(0.0, 1.0));
|
|
7186
|
+
float d = riverHash(i + vec2(1.0, 1.0));
|
|
7187
|
+
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
|
|
7188
|
+
}
|
|
7189
|
+
`;
|
|
7190
|
+
const RIVER_VERT = `
|
|
7191
|
+
precision highp float;
|
|
7192
|
+
|
|
7193
|
+
#include <fog_pars_vertex>
|
|
7194
|
+
|
|
7195
|
+
uniform float uTime;
|
|
7196
|
+
uniform float uStandingAmp;
|
|
7197
|
+
uniform float uGlideAmp;
|
|
7198
|
+
|
|
7199
|
+
// (downstream m, across m) — the flow frame every pattern lives in
|
|
7200
|
+
attribute vec2 aFlowUv;
|
|
7201
|
+
// unit downstream direction in XZ
|
|
7202
|
+
attribute vec2 aFlow;
|
|
7203
|
+
// (speed m/s, water column m, grade, across ∈ [-overshoot, +overshoot])
|
|
7204
|
+
attribute vec4 aRiver;
|
|
7205
|
+
|
|
7206
|
+
varying vec3 vWorldPosition;
|
|
7207
|
+
varying vec3 vSurfaceNormal;
|
|
7208
|
+
varying vec2 vFlowUv;
|
|
7209
|
+
varying vec2 vFlow;
|
|
7210
|
+
varying vec4 vRiver;
|
|
7211
|
+
|
|
7212
|
+
void main() {
|
|
7213
|
+
vFlowUv = aFlowUv;
|
|
7214
|
+
vFlow = aFlow;
|
|
7215
|
+
vRiver = aRiver;
|
|
7216
|
+
|
|
7217
|
+
float column = aRiver.y;
|
|
7218
|
+
float speed = aRiver.x;
|
|
7219
|
+
float grade = aRiver.z;
|
|
7220
|
+
float across = aRiver.w;
|
|
7221
|
+
|
|
7222
|
+
// displace only where there IS water: a bank vertex whose column has run out
|
|
7223
|
+
// must stay welded to the ground or the ribbon lifts off the shore
|
|
7224
|
+
float body = smoothstep(0.0, 0.3, column) * (1.0 - smoothstep(0.7, 1.0, abs(across)));
|
|
7225
|
+
|
|
7226
|
+
// hydraulic jump: in a chute the wave train stands still in the world (phase
|
|
7227
|
+
// on the downstream coordinate, NEVER scrolled) and only breathes in time
|
|
7228
|
+
float chute = smoothstep(0.035, 0.16, grade) * smoothstep(0.6, 2.4, speed);
|
|
7229
|
+
float standing =
|
|
7230
|
+
sin(aFlowUv.x * 1.9 + sin(uTime * 0.7) * 0.35) * 0.6 +
|
|
7231
|
+
sin(aFlowUv.x * 3.6 + sin(uTime * 0.9) * 0.5) * 0.4;
|
|
7232
|
+
// calm reach: one long low undulation gliding downstream with the current
|
|
7233
|
+
float glide = sin(aFlowUv.x * 0.5 - uTime * speed * 0.5 + aFlowUv.y * 0.3);
|
|
7234
|
+
|
|
7235
|
+
vec3 displaced = position;
|
|
7236
|
+
displaced.y += body * (standing * chute * uStandingAmp + glide * uGlideAmp * (1.0 - chute));
|
|
7237
|
+
|
|
7238
|
+
vec4 world = modelMatrix * vec4(displaced, 1.0);
|
|
7239
|
+
vWorldPosition = world.xyz;
|
|
7240
|
+
vSurfaceNormal = normalize(mat3(modelMatrix) * normal);
|
|
7241
|
+
// three's fog chunk reads mvPosition — the built-in materials get it from
|
|
7242
|
+
// the project_vertex chunk, which a hand-written vertex stage stands in for
|
|
7243
|
+
vec4 mvPosition = viewMatrix * world;
|
|
7244
|
+
gl_Position = projectionMatrix * mvPosition;
|
|
7245
|
+
|
|
7246
|
+
#include <fog_vertex>
|
|
7247
|
+
}
|
|
7248
|
+
`;
|
|
7249
|
+
const RIVER_FRAG = `
|
|
7250
|
+
precision highp float;
|
|
7251
|
+
|
|
7252
|
+
#include <fog_pars_fragment>
|
|
7253
|
+
|
|
7254
|
+
uniform float uTime;
|
|
7255
|
+
uniform vec3 uShallowColor;
|
|
7256
|
+
uniform vec3 uDeepColor;
|
|
7257
|
+
uniform vec3 uFoamColor;
|
|
7258
|
+
uniform vec3 uSkyColor;
|
|
7259
|
+
uniform vec3 uHorizonColor;
|
|
7260
|
+
uniform vec3 uBankColor;
|
|
7261
|
+
uniform vec3 uSunDirection;
|
|
7262
|
+
uniform vec3 uSunColor;
|
|
7263
|
+
uniform float uSunIntensity;
|
|
7264
|
+
uniform float uMaxDepth;
|
|
7265
|
+
uniform float uAbsorption;
|
|
7266
|
+
uniform float uOpacity;
|
|
7267
|
+
uniform float uFoam;
|
|
7268
|
+
uniform float uRippleScale;
|
|
7269
|
+
uniform float uRippleStrength;
|
|
7270
|
+
uniform float uFlowRate;
|
|
7271
|
+
uniform float uSparkle;
|
|
7272
|
+
uniform float uFresnelScale;
|
|
7273
|
+
|
|
7274
|
+
varying vec3 vWorldPosition;
|
|
7275
|
+
varying vec3 vSurfaceNormal;
|
|
7276
|
+
varying vec2 vFlowUv;
|
|
7277
|
+
varying vec2 vFlow;
|
|
7278
|
+
varying vec4 vRiver;
|
|
7279
|
+
|
|
7280
|
+
${RIVER_NOISE}
|
|
7281
|
+
|
|
7282
|
+
/**
|
|
7283
|
+
* Ripple field in flow space: (height, d/d-downstream, d/d-across).
|
|
7284
|
+
*
|
|
7285
|
+
* Anisotropic on purpose — the downstream axis is squeezed so crests run
|
|
7286
|
+
* ACROSS the channel, which is what wavelets on running water actually do.
|
|
7287
|
+
*/
|
|
7288
|
+
vec3 riverRipple(vec2 p) {
|
|
7289
|
+
vec2 q = vec2(p.x * 1.7, p.y * 0.55);
|
|
7290
|
+
float h = riverNoise(q) * 0.62 + riverNoise(q * 2.4 + 13.1) * 0.38;
|
|
7291
|
+
float e = 0.35;
|
|
7292
|
+
vec2 qa = vec2((p.x + e) * 1.7, p.y * 0.55);
|
|
7293
|
+
vec2 qb = vec2(p.x * 1.7, (p.y + e) * 0.55);
|
|
7294
|
+
float ha = riverNoise(qa) * 0.62 + riverNoise(qa * 2.4 + 13.1) * 0.38;
|
|
7295
|
+
float hb = riverNoise(qb) * 0.62 + riverNoise(qb * 2.4 + 13.1) * 0.38;
|
|
7296
|
+
return vec3(h, (ha - h) / e, (hb - h) / e);
|
|
7297
|
+
}
|
|
7298
|
+
|
|
7299
|
+
/** Foam streaks: stretched downstream, so froth reads as trails, but broken
|
|
7300
|
+
* enough across the channel that it never becomes a painted stripe. */
|
|
7301
|
+
float riverStreak(vec2 p) {
|
|
7302
|
+
vec2 q = vec2(p.x * 0.5, p.y * 2.0);
|
|
7303
|
+
return riverNoise(q) * 0.5 + riverNoise(q * 3.3 + 7.3) * 0.5;
|
|
7304
|
+
}
|
|
7305
|
+
|
|
7306
|
+
void main() {
|
|
7307
|
+
float speed = vRiver.x;
|
|
7308
|
+
float column = vRiver.y;
|
|
7309
|
+
float grade = vRiver.z;
|
|
7310
|
+
float across = vRiver.w;
|
|
7311
|
+
|
|
7312
|
+
vec2 dir = normalize(vFlow + vec2(1e-5, 0.0));
|
|
7313
|
+
vec3 dir3 = vec3(dir.x, 0.0, dir.y);
|
|
7314
|
+
vec3 perp3 = vec3(-dir.y, 0.0, dir.x);
|
|
7315
|
+
|
|
7316
|
+
// ── dual-phase advection ────────────────────────────────────────────────
|
|
7317
|
+
// A field scrolled at a speed that varies along the river stretches a little
|
|
7318
|
+
// more every frame until it smears. Run two copies half a period apart and
|
|
7319
|
+
// cross-fade with a triangle wave: whichever copy is near its wrap is the one
|
|
7320
|
+
// being faded out, so the reset never shows.
|
|
7321
|
+
float phase = uTime * uFlowRate;
|
|
7322
|
+
float pA = fract(phase);
|
|
7323
|
+
float pB = fract(phase + 0.5);
|
|
7324
|
+
float blend = abs(pA - 0.5) * 2.0;
|
|
7325
|
+
vec2 base = vFlowUv * uRippleScale;
|
|
7326
|
+
float travel = speed * uRippleScale / max(uFlowRate, 0.001);
|
|
7327
|
+
|
|
7328
|
+
vec3 ripA = riverRipple(base - vec2(travel * pA, 0.0));
|
|
7329
|
+
vec3 ripB = riverRipple(base - vec2(travel * pB, 0.0));
|
|
7330
|
+
vec3 ripple = mix(ripA, ripB, blend);
|
|
7331
|
+
|
|
7332
|
+
float streakA = riverStreak(base * 0.55 - vec2(travel * pA * 0.55, 0.0));
|
|
7333
|
+
float streakB = riverStreak(base * 0.55 - vec2(travel * pB * 0.55, 0.0));
|
|
7334
|
+
float streak = mix(streakA, streakB, blend);
|
|
7335
|
+
|
|
7336
|
+
// ripples flatten out where the water thins to a film over the bed
|
|
7337
|
+
float lively = smoothstep(0.02, 0.22, column) * (0.45 + 0.55 * smoothstep(0.1, 1.6, speed));
|
|
7338
|
+
// the gradient is a NOISE slope, not a water slope: clamp it before it tilts
|
|
7339
|
+
// the normal past a real wavelet (an over-driven normal throws fresnel to its
|
|
7340
|
+
// maximum everywhere and the river turns into a sheet of white sky)
|
|
7341
|
+
vec2 slopeXY = clamp(ripple.yz * uRippleStrength * lively, vec2(-0.5), vec2(0.5));
|
|
7342
|
+
vec3 normal = normalize(vSurfaceNormal - dir3 * slopeXY.x - perp3 * slopeXY.y);
|
|
7343
|
+
|
|
7344
|
+
vec3 viewDir = normalize(cameraPosition - vWorldPosition);
|
|
7345
|
+
|
|
7346
|
+
// ── whitewater ──────────────────────────────────────────────────────────
|
|
7347
|
+
// three independent births, all physical: a steep fast reach breaks into
|
|
7348
|
+
// rapids, the shear line along a bank froths, and a column thin enough to
|
|
7349
|
+
// scrape the bed riffles over it
|
|
7350
|
+
float rapids = smoothstep(0.07, 0.22, grade) * smoothstep(1.2, 3.0, speed);
|
|
7351
|
+
// bank froth comes and goes along a shear line — a continuous white hem
|
|
7352
|
+
// around the whole river reads as a drawn outline, never as water
|
|
7353
|
+
float bankPatch = smoothstep(0.35, 0.75, riverStreak(base * 0.22 - vec2(travel * 0.12, 0.0)));
|
|
7354
|
+
float bankShear =
|
|
7355
|
+
smoothstep(0.74, 1.0, abs(across)) * smoothstep(0.8, 2.4, speed) * (0.35 + 0.65 * bankPatch);
|
|
7356
|
+
// shallow is RELATIVE to this river: a hand's depth is a riffle in a brook
|
|
7357
|
+
// and nothing at all in a millrace, so the thresholds ride uMaxDepth
|
|
7358
|
+
float shallowT = column / max(uMaxDepth, 0.05);
|
|
7359
|
+
float riffle = (1.0 - smoothstep(0.12, 0.42, shallowT)) * smoothstep(0.7, 2.2, speed);
|
|
7360
|
+
float drive = clamp(rapids * 1.0 + bankShear * 0.35 + riffle * 0.5, 0.0, 1.3) * uFoam;
|
|
7361
|
+
// COVERAGE is the thing to be strict about: whitewater is where the surface
|
|
7362
|
+
// has actually broken, and a river that fizzes everywhere reads as milk
|
|
7363
|
+
float foam = smoothstep(0.74 - 0.45 * drive, 1.02 - 0.24 * drive, streak) * min(drive, 1.0);
|
|
7364
|
+
// crest froth: the tops of standing waves in a real rapid always carry white
|
|
7365
|
+
foam = max(foam, smoothstep(0.68, 0.96, ripple.x) * smoothstep(0.4, 1.0, rapids) * uFoam * 0.5);
|
|
7366
|
+
foam = clamp(foam, 0.0, 1.0);
|
|
7367
|
+
|
|
7368
|
+
// ── body ────────────────────────────────────────────────────────────────
|
|
7369
|
+
float depthT = clamp(column / max(uMaxDepth, 0.01), 0.0, 1.0);
|
|
7370
|
+
// THREE bands, because a two-stop ramp lies about thin water. A film over the
|
|
7371
|
+
// bank does not lighten the ground, it DARKENS it — soaked stone, soaked
|
|
7372
|
+
// gravel — and only once there is a real column does the water start showing
|
|
7373
|
+
// its own colour. Skipping the wet band is what makes shallow game water look
|
|
7374
|
+
// like a sheet of cellophane laid over the terrain.
|
|
7375
|
+
vec3 wet = uDeepColor * 0.45;
|
|
7376
|
+
vec3 body = mix(wet, uShallowColor, smoothstep(0.0, 0.3, depthT));
|
|
7377
|
+
body = mix(body, uDeepColor, smoothstep(0.28, 0.78, depthT));
|
|
7378
|
+
// sunlight focused by the ripples dances on the bed of a shallow reach
|
|
7379
|
+
float dapple = smoothstep(0.55, 0.95, ripple.x) * (1.0 - smoothstep(0.15, 0.8, depthT));
|
|
7380
|
+
body += uSunColor * dapple * 0.14 * uSunIntensity;
|
|
7381
|
+
|
|
7382
|
+
// Beer's law decides how much of the bed the water still lets through — this
|
|
7383
|
+
// surface composites over the terrain, so absorption IS the alpha curve
|
|
7384
|
+
float hide = 1.0 - exp(-uAbsorption * column * 3.2);
|
|
7385
|
+
|
|
7386
|
+
// ── sky + sun ───────────────────────────────────────────────────────────
|
|
7387
|
+
// What a river actually mirrors depends on how far up the reflected ray
|
|
7388
|
+
// goes: almost every fragment of a stream is seen at a grazing angle, and
|
|
7389
|
+
// those rays leave nearly horizontal — into the far BANK (dark trees, wet
|
|
7390
|
+
// rock), not the zenith. Reflecting sky everywhere is what turns game water
|
|
7391
|
+
// into a sheet of white plastic.
|
|
7392
|
+
vec3 reflectDir = reflect(-viewDir, normal);
|
|
7393
|
+
// the far bank fills a surprising slice of the reflected hemisphere when the
|
|
7394
|
+
// eye is near the water — hold its dark tone well past the horizon line
|
|
7395
|
+
vec3 sky = mix(uBankColor, uHorizonColor, smoothstep(0.05, 0.34, reflectDir.y));
|
|
7396
|
+
sky = mix(sky, uSkyColor, smoothstep(0.34, 0.85, reflectDir.y));
|
|
7397
|
+
float fresnel = clamp(
|
|
7398
|
+
uFresnelScale * pow(1.0 - clamp(dot(normal, viewDir), 0.0, 1.0), 5.0),
|
|
7399
|
+
0.0,
|
|
7400
|
+
0.82
|
|
7401
|
+
);
|
|
7402
|
+
|
|
7403
|
+
vec3 sunDir = normalize(uSunDirection);
|
|
7404
|
+
vec3 halfVec = normalize(sunDir + viewDir);
|
|
7405
|
+
float ndh = max(dot(normal, halfVec), 0.0);
|
|
7406
|
+
// two lobes: a tight glitter spark plus a broad sheen. The sheen is what
|
|
7407
|
+
// makes a river READ as a moving surface from across the valley, where the
|
|
7408
|
+
// sparkle is smaller than a pixel.
|
|
7409
|
+
float spec = (pow(ndh, 96.0) + pow(ndh, 18.0) * 0.22) * uSunIntensity;
|
|
7410
|
+
// glitter: the same advected field gates the highlight so it breaks into
|
|
7411
|
+
// travelling sparks instead of one smooth sheen
|
|
7412
|
+
float glint = smoothstep(0.55, 0.95, ripple.x);
|
|
7413
|
+
spec *= 0.45 + 0.55 * glint * uSparkle;
|
|
7414
|
+
spec *= 1.0 - foam * 0.85; // froth is rough — it kills the mirror
|
|
7415
|
+
|
|
7416
|
+
vec3 color = mix(body, sky, fresnel);
|
|
7417
|
+
color += uSunColor * spec * 1.6;
|
|
7418
|
+
color = mix(color, uFoamColor, foam * 0.88);
|
|
7419
|
+
|
|
7420
|
+
// ── edges ───────────────────────────────────────────────────────────────
|
|
7421
|
+
// the ribbon is built wider than the channel; what makes its outline is the
|
|
7422
|
+
// water running out, so bars and boulders cut it exactly like the ground does
|
|
7423
|
+
float edge = smoothstep(0.0, 0.06, column) * (1.0 - smoothstep(0.84, 1.0, abs(across)));
|
|
7424
|
+
float alpha = clamp(max(hide * uOpacity, fresnel * 0.85), 0.0, 1.0);
|
|
7425
|
+
alpha = clamp(max(alpha, foam * 0.95) * edge, 0.0, 1.0);
|
|
7426
|
+
|
|
7427
|
+
gl_FragColor = vec4(color, alpha);
|
|
7428
|
+
|
|
7429
|
+
#include <tonemapping_fragment>
|
|
7430
|
+
#include <colorspace_fragment>
|
|
7431
|
+
#include <fog_fragment>
|
|
7432
|
+
}
|
|
7433
|
+
`;
|
|
7434
|
+
//#endregion
|
|
7435
|
+
//#region src/3d/nodes/river-3d.ts
|
|
7436
|
+
/** Ribbon half-width as a multiple of the channel's — the water column fades
|
|
7437
|
+
* out inside the overshoot, so the bank is drawn by the ground, not by an edge. */
|
|
7438
|
+
const BANK_OVERSHOOT = 1.25;
|
|
7439
|
+
/** Centerline station spacing, derived from the narrowest reach. */
|
|
7440
|
+
const STEP_MIN = .6;
|
|
7441
|
+
const STEP_MAX = 2.5;
|
|
7442
|
+
const STEP_PER_WIDTH = .45;
|
|
7443
|
+
/** Quads across the channel — enough for the bank fade to read as a gradient. */
|
|
7444
|
+
const ACROSS_MIN = 4;
|
|
7445
|
+
const ACROSS_MAX = 12;
|
|
7446
|
+
const METERS_PER_ACROSS_SEGMENT = 1.5;
|
|
7447
|
+
/** Water-column averaging radius — small enough that a boulder still thins the
|
|
7448
|
+
* flow over it, wide enough that terrain grain does not fizz the whole river. */
|
|
7449
|
+
const COLUMN_BLUR_PER_HALF_WIDTH = .35;
|
|
7450
|
+
const COLUMN_BLUR_MAX = 1.2;
|
|
7451
|
+
/** How hard the current drags a body toward the water's own velocity (1/s at
|
|
7452
|
+
* `flowForce: 1`). Drag, not thrust: nothing ever outruns the river. Tuned
|
|
7453
|
+
* against CharacterController3D, whose stand-still damping fights it — at this
|
|
7454
|
+
* rate a person planted in a 2 m/s creek slides, which is what a 2 m/s creek
|
|
7455
|
+
* does to a person, while a free-floating raft still converges on the current. */
|
|
7456
|
+
const CURRENT_DRAG = 6;
|
|
7457
|
+
/** A body counts as in the current while its feet are under the surface and it
|
|
7458
|
+
* has not sunk more than this far below the bed. */
|
|
7459
|
+
const CURRENT_SURFACE_SLACK = .05;
|
|
7460
|
+
const CURRENT_UNDER_SLACK = 1.5;
|
|
7461
|
+
const DEFAULT_COLORS = {
|
|
7462
|
+
shallow: "#74b2a2",
|
|
7463
|
+
deep: "#123f4b",
|
|
7464
|
+
sky: "#8ab6d8",
|
|
7465
|
+
horizon: "#9fb6c2",
|
|
7466
|
+
bank: "#465447"
|
|
7467
|
+
};
|
|
7468
|
+
const FOAM_COLOR = "#e9f2ef";
|
|
7469
|
+
/** Fixed look constants — the props stay intent-level, these carry the craft. */
|
|
7470
|
+
const LOOK = {
|
|
7471
|
+
/** Pattern units per meter of flow space. */
|
|
7472
|
+
rippleScale: .85,
|
|
7473
|
+
/** Ripple normal SLOPE (wavelets are centimetres — a big value reads as
|
|
7474
|
+
* churning glass, not water). */
|
|
7475
|
+
rippleStrength: .09,
|
|
7476
|
+
/** Advection phase periods per second (the dual-phase crossfade rate). */
|
|
7477
|
+
flowRate: .42,
|
|
7478
|
+
fresnelScale: .95,
|
|
7479
|
+
/** Standing-wave height in a chute, meters. */
|
|
7480
|
+
standingAmp: .11,
|
|
7481
|
+
/** Long glide undulation on a calm reach, meters. */
|
|
7482
|
+
glideAmp: .025
|
|
7483
|
+
};
|
|
7484
|
+
/**
|
|
7485
|
+
* A river: running water that finds its own shape.
|
|
7486
|
+
*
|
|
7487
|
+
* A scene declares a centerline `path`, a `width` (constant or a profile) and a
|
|
7488
|
+
* `depth`; the node samples the Terrain3D underneath and derives everything
|
|
7489
|
+
* else — a surface that descends with the ground and never climbs, a current
|
|
7490
|
+
* that speeds up where the channel pinches or tips, whitewater wherever those
|
|
7491
|
+
* two make it, and banks cut by the ground itself (each vertex carries its own
|
|
7492
|
+
* water column, and the shader stops drawing where that runs out).
|
|
7493
|
+
*
|
|
7494
|
+
* Unlike {@link Water3D} it costs no extra render passes — a river is a ribbon
|
|
7495
|
+
* with one draw call, so a map can carry a dozen of them.
|
|
7496
|
+
*
|
|
7497
|
+
* ```json
|
|
7498
|
+
* { "type": "River3D", "props": {
|
|
7499
|
+
* "path": [[-80, -30], [-20, 5], [30, 10], [90, -10]],
|
|
7500
|
+
* "widths": [3, 6, 11], "depth": 0.9, "flowSpeed": 1.8 } }
|
|
7501
|
+
* ```
|
|
7502
|
+
*/
|
|
7503
|
+
var River3D = class River3D extends Node3D {
|
|
7504
|
+
static typeName = "River3D";
|
|
7505
|
+
static props = {
|
|
7506
|
+
path: { default: [] },
|
|
7507
|
+
width: { default: 6 },
|
|
7508
|
+
widths: { default: [] },
|
|
7509
|
+
depth: { default: .8 },
|
|
7510
|
+
flowSpeed: { default: 1.6 },
|
|
7511
|
+
colors: { default: {} },
|
|
7512
|
+
absorption: { default: .5 },
|
|
7513
|
+
opacity: { default: .85 },
|
|
7514
|
+
foam: { default: 1 },
|
|
7515
|
+
ripples: { default: 1 },
|
|
7516
|
+
sunDirection: { default: [
|
|
7517
|
+
.5,
|
|
7518
|
+
.8,
|
|
7519
|
+
.3
|
|
7520
|
+
] },
|
|
7521
|
+
sunColor: { default: "#fff6e0" },
|
|
7522
|
+
sunIntensity: { default: 1 },
|
|
7523
|
+
terrain: { default: "" },
|
|
7524
|
+
flowForce: { default: 1 }
|
|
7525
|
+
};
|
|
7526
|
+
/** Centerline control points `[[x, z], …]` in the node's local frame. */
|
|
7527
|
+
path = [];
|
|
7528
|
+
/** Channel width in meters (constant unless `widths` overrides it). */
|
|
7529
|
+
width = 6;
|
|
7530
|
+
/** Width profile lerped source→mouth, e.g. `[2, 5, 9]` for a stream growing
|
|
7531
|
+
* into a river. Empty = the constant `width`. */
|
|
7532
|
+
widths = [];
|
|
7533
|
+
/** Water column at the centerline, in meters. */
|
|
7534
|
+
depth = .8;
|
|
7535
|
+
/** Reference current in m/s (the mean; reaches speed up and slow down). */
|
|
7536
|
+
flowSpeed = 1.6;
|
|
7537
|
+
/** `{ shallow, deep, sky, horizon, bank }` — the body ramp plus what the
|
|
7538
|
+
* surface mirrors (a grazing river mostly mirrors its BANK, not the sky). */
|
|
7539
|
+
colors = {};
|
|
7540
|
+
/** Beer's-law density: how fast the water hides its bed. */
|
|
7541
|
+
absorption = .5;
|
|
7542
|
+
/** Upper bound on the body's opacity (1 = the deep reach goes solid). */
|
|
7543
|
+
opacity = .85;
|
|
7544
|
+
/** Whitewater dial: 0 = a glassy canal, 1 = default, 2 = raging. */
|
|
7545
|
+
foam = 1;
|
|
7546
|
+
/** Surface-detail dial: ripple relief and glitter. */
|
|
7547
|
+
ripples = 1;
|
|
7548
|
+
/** Glint direction (the sky's sun overrides while this is at its default). */
|
|
7549
|
+
sunDirection = [
|
|
7550
|
+
.5,
|
|
7551
|
+
.8,
|
|
7552
|
+
.3
|
|
7553
|
+
];
|
|
7554
|
+
sunColor = "#fff6e0";
|
|
7555
|
+
sunIntensity = 1;
|
|
7556
|
+
/** Drape target path; empty = auto-find the first Terrain3D in the tree. */
|
|
7557
|
+
terrain = "";
|
|
7558
|
+
/** How hard the current sweeps bodies downstream (0 = visual only). */
|
|
7559
|
+
flowForce = 1;
|
|
7560
|
+
renderOrder = 1;
|
|
7561
|
+
time = 0;
|
|
7562
|
+
rings = [];
|
|
7563
|
+
courseKey = "";
|
|
7564
|
+
bedAt = null;
|
|
7565
|
+
world = [
|
|
7566
|
+
0,
|
|
7567
|
+
0,
|
|
7568
|
+
0
|
|
7569
|
+
];
|
|
7570
|
+
_bodies = [];
|
|
7571
|
+
/** Loader hook: a malformed river fails at LOAD, not as an invisible ribbon. */
|
|
7572
|
+
static validateJson(node) {
|
|
7573
|
+
const r = node;
|
|
7574
|
+
if (!Array.isArray(r.path)) throw new IncantoError("BAD_FORMAT", `River3D '${node.name}' path must be an array of [x, z] points, got ${JSON.stringify(r.path)}.`, { prop: "path" });
|
|
7575
|
+
if (r.path.length > 0 && r.path.length < 2) throw new IncantoError("BAD_FORMAT", `River3D '${node.name}' path needs at least 2 points (source → mouth), got ${r.path.length}.`, { prop: "path" });
|
|
7576
|
+
r.path.forEach((p, i) => {
|
|
7577
|
+
if (!Array.isArray(p) || p.length < 2 || !Number.isFinite(p[0]) || !Number.isFinite(p[1])) throw new IncantoError("BAD_FORMAT", `River3D '${node.name}' path[${i}] must be [x, z] with finite numbers, got ${JSON.stringify(p)}.`, { prop: "path" });
|
|
7578
|
+
});
|
|
7579
|
+
if (!(typeof r.width === "number" && r.width > 0)) throw new IncantoError("BAD_FORMAT", `River3D '${node.name}' width must be a positive number of meters, got ${JSON.stringify(r.width)}.`, { prop: "width" });
|
|
7580
|
+
if (!Array.isArray(r.widths) || r.widths.some((w) => !(typeof w === "number" && w > 0))) throw new IncantoError("BAD_FORMAT", `River3D '${node.name}' widths must be a list of positive meters (source→mouth), got ${JSON.stringify(r.widths)}.`, { prop: "widths" });
|
|
7581
|
+
if (!(r.depth > 0)) throw new IncantoError("BAD_FORMAT", `River3D '${node.name}' depth must be > 0 meters, got ${JSON.stringify(r.depth)}.`, { prop: "depth" });
|
|
7582
|
+
if (!(r.flowSpeed >= 0)) throw new IncantoError("BAD_FORMAT", `River3D '${node.name}' flowSpeed must be >= 0 m/s, got ${JSON.stringify(r.flowSpeed)}.`, { prop: "flowSpeed" });
|
|
7583
|
+
for (const key of [
|
|
7584
|
+
"absorption",
|
|
7585
|
+
"foam",
|
|
7586
|
+
"ripples",
|
|
7587
|
+
"flowForce",
|
|
7588
|
+
"sunIntensity"
|
|
7589
|
+
]) {
|
|
7590
|
+
const v = r[key];
|
|
7591
|
+
if (!(typeof v === "number" && v >= 0)) throw new IncantoError("BAD_FORMAT", `River3D '${node.name}' ${key} must be a number >= 0, got ${JSON.stringify(v)}.`, { prop: key });
|
|
7592
|
+
}
|
|
7593
|
+
if (!(r.opacity >= 0 && r.opacity <= 1)) throw new IncantoError("BAD_FORMAT", `River3D '${node.name}' opacity must be within [0, 1], got ${JSON.stringify(r.opacity)}.`, { prop: "opacity" });
|
|
7594
|
+
const colors = r.colors;
|
|
7595
|
+
if (typeof colors !== "object" || colors === null || Array.isArray(colors)) throw new IncantoError("BAD_FORMAT", `River3D '${node.name}' colors must be an object of { shallow, deep, sky, horizon, bank } hex strings.`, { prop: "colors" });
|
|
7596
|
+
for (const key of Object.keys(colors)) {
|
|
7597
|
+
if (!(key in DEFAULT_COLORS)) throw new IncantoError("BAD_FORMAT", `River3D '${node.name}' colors has unknown key '${key}' — expected shallow, deep, sky, horizon or bank.`, { prop: "colors" });
|
|
7598
|
+
if (typeof colors[key] !== "string") throw new IncantoError("BAD_FORMAT", `River3D '${node.name}' colors.${key} must be a hex string, got ${JSON.stringify(colors[key])}.`, { prop: "colors" });
|
|
7599
|
+
}
|
|
7600
|
+
}
|
|
7601
|
+
_createObject3D() {
|
|
7602
|
+
return new Mesh(new BufferGeometry(), this.buildMaterial());
|
|
7603
|
+
}
|
|
7604
|
+
update(dt) {
|
|
7605
|
+
this.time += dt;
|
|
7606
|
+
this.ensureCourse();
|
|
7607
|
+
if (this.flowForce > 0 && this.rings.length > 1) this.sweepBodies(dt);
|
|
7608
|
+
}
|
|
7609
|
+
_syncObject3D() {
|
|
7610
|
+
super._syncObject3D();
|
|
7611
|
+
const mesh = this._ensureObject3D();
|
|
7612
|
+
this.rebuildIfNeeded(mesh);
|
|
7613
|
+
this.syncUniforms(mesh);
|
|
7614
|
+
}
|
|
7615
|
+
/**
|
|
7616
|
+
* Where a WORLD point sits in the channel — the query gameplay scripts run
|
|
7617
|
+
* (steer a raft, drown a torch, decide a ford is too fast to cross). `null`
|
|
7618
|
+
* until the river has been built.
|
|
7619
|
+
*/
|
|
7620
|
+
sampleAt(x, z) {
|
|
7621
|
+
this.ensureCourse();
|
|
7622
|
+
if (this.rings.length === 0) return null;
|
|
7623
|
+
const [wx, wy, wz] = this.world;
|
|
7624
|
+
const hit = projectToRiver(this.rings, x - wx, z - wz);
|
|
7625
|
+
return hit ? {
|
|
7626
|
+
...hit,
|
|
7627
|
+
surfaceY: hit.surfaceY + wy
|
|
7628
|
+
} : null;
|
|
7629
|
+
}
|
|
7630
|
+
/** @internal The derived centerline stations (surface, grade, current). */
|
|
7631
|
+
_rings() {
|
|
7632
|
+
return this.rings;
|
|
7633
|
+
}
|
|
7634
|
+
/**
|
|
7635
|
+
* @internal Environment-sky sun hand-off (see {@link SunConsumer3D}): only
|
|
7636
|
+
* while `sunDirection` sits at its schema default — an authored glint wins.
|
|
7637
|
+
*/
|
|
7638
|
+
_applySunDirection(dir) {
|
|
7639
|
+
const def = River3D.props.sunDirection?.default;
|
|
7640
|
+
if (!this.sunDirection.every((v, i) => v === def[i])) return;
|
|
7641
|
+
(this._ensureObject3D().material.uniforms?.uSunDirection?.value)?.set(dir[0], dir[1], dir[2]);
|
|
7642
|
+
}
|
|
7643
|
+
free() {
|
|
7644
|
+
const mesh = this._ensureObject3D();
|
|
7645
|
+
mesh.geometry?.dispose();
|
|
7646
|
+
mesh.material?.dispose?.();
|
|
7647
|
+
super.free();
|
|
7648
|
+
}
|
|
7649
|
+
widthProfile() {
|
|
7650
|
+
return this.widths.length > 0 ? this.widths : [this.width];
|
|
7651
|
+
}
|
|
7652
|
+
/** Resolve the drape terrain; an explicit path that misses is a hard error. */
|
|
7653
|
+
resolveTerrain() {
|
|
7654
|
+
const terrain = findDrapeTerrain(this, this.terrain);
|
|
7655
|
+
if (!terrain && this.terrain !== "") throw new IncantoError("NODE_NOT_FOUND", `River3D '${this.name}' terrain '${this.terrain}' is not a Terrain3D in the tree.`, { prop: "terrain" });
|
|
7656
|
+
return terrain;
|
|
7657
|
+
}
|
|
7658
|
+
/**
|
|
7659
|
+
* Derive the course (stations + water column) when a shaping prop changed.
|
|
7660
|
+
* Pure CPU work — no three objects — so gameplay can call it any time.
|
|
7661
|
+
*/
|
|
7662
|
+
ensureCourse() {
|
|
7663
|
+
const terrain = this.resolveTerrain();
|
|
7664
|
+
const world = worldXZY(this);
|
|
7665
|
+
const key = JSON.stringify([
|
|
7666
|
+
this.path,
|
|
7667
|
+
this.width,
|
|
7668
|
+
this.widths,
|
|
7669
|
+
this.depth,
|
|
7670
|
+
this.flowSpeed,
|
|
7671
|
+
terrain ? terrain.name : "",
|
|
7672
|
+
world
|
|
7673
|
+
]);
|
|
7674
|
+
if (key === this.courseKey) return false;
|
|
7675
|
+
this.courseKey = key;
|
|
7676
|
+
this.world = world;
|
|
7677
|
+
this.bedAt = null;
|
|
7678
|
+
if (this.path.length < 2) {
|
|
7679
|
+
this.rings = [];
|
|
7680
|
+
return true;
|
|
7681
|
+
}
|
|
7682
|
+
const [wx, wy, wz] = world;
|
|
7683
|
+
this.bedAt = terrain ? (lx, lz) => terrain.heightAt(lx + wx, lz + wz) - wy : () => -this.depth;
|
|
7684
|
+
const widths = this.widthProfile();
|
|
7685
|
+
const narrow = Math.min(...widths);
|
|
7686
|
+
this.rings = buildRiverRings({
|
|
7687
|
+
path: this.path,
|
|
7688
|
+
widths,
|
|
7689
|
+
depth: this.depth,
|
|
7690
|
+
flowSpeed: this.flowSpeed,
|
|
7691
|
+
step: Math.min(Math.max(narrow * STEP_PER_WIDTH, STEP_MIN), STEP_MAX),
|
|
7692
|
+
bedAt: this.bedAt
|
|
7693
|
+
});
|
|
7694
|
+
return true;
|
|
7695
|
+
}
|
|
7696
|
+
rebuildIfNeeded(mesh) {
|
|
7697
|
+
if (!this.ensureCourse() && mesh.geometry.getAttribute("position")) return;
|
|
7698
|
+
if (this.rings.length === 0 || !this.bedAt) {
|
|
7699
|
+
mesh.geometry.dispose();
|
|
7700
|
+
const empty = new BufferGeometry();
|
|
7701
|
+
empty.setAttribute("position", new BufferAttribute(new Float32Array(0), 3));
|
|
7702
|
+
mesh.geometry = empty;
|
|
7703
|
+
return;
|
|
7704
|
+
}
|
|
7705
|
+
const widths = this.widthProfile();
|
|
7706
|
+
const narrow = Math.min(...widths);
|
|
7707
|
+
const wide = Math.max(...widths);
|
|
7708
|
+
const data = buildRiverGeometry(this.rings, {
|
|
7709
|
+
acrossSegments: Math.min(ACROSS_MAX, Math.max(ACROSS_MIN, Math.round(wide / METERS_PER_ACROSS_SEGMENT))),
|
|
7710
|
+
bankOvershoot: BANK_OVERSHOOT,
|
|
7711
|
+
bedBlur: Math.min(narrow / 2 * COLUMN_BLUR_PER_HALF_WIDTH, COLUMN_BLUR_MAX),
|
|
7712
|
+
bedAt: this.bedAt
|
|
7713
|
+
});
|
|
7714
|
+
const geometry = new BufferGeometry();
|
|
7715
|
+
geometry.setAttribute("position", new BufferAttribute(data.positions, 3));
|
|
7716
|
+
geometry.setAttribute("normal", new BufferAttribute(data.normals, 3));
|
|
7717
|
+
geometry.setAttribute("aFlowUv", new BufferAttribute(data.uvs, 2));
|
|
7718
|
+
geometry.setAttribute("aFlow", new BufferAttribute(data.flow, 2));
|
|
7719
|
+
geometry.setAttribute("aRiver", new BufferAttribute(data.river, 4));
|
|
7720
|
+
geometry.setIndex(new BufferAttribute(data.indices, 1));
|
|
7721
|
+
geometry.computeBoundingSphere();
|
|
7722
|
+
mesh.geometry.dispose();
|
|
7723
|
+
mesh.geometry = geometry;
|
|
7724
|
+
}
|
|
7725
|
+
buildMaterial() {
|
|
7726
|
+
return new ShaderMaterial({
|
|
7727
|
+
vertexShader: RIVER_VERT,
|
|
7728
|
+
fragmentShader: RIVER_FRAG,
|
|
7729
|
+
fog: true,
|
|
7730
|
+
uniforms: {
|
|
7731
|
+
fogColor: { value: new Color("#ffffff") },
|
|
7732
|
+
fogNear: { value: 1 },
|
|
7733
|
+
fogFar: { value: 1e3 },
|
|
7734
|
+
fogDensity: { value: 25e-5 },
|
|
7735
|
+
uTime: { value: 0 },
|
|
7736
|
+
uShallowColor: { value: new Color(DEFAULT_COLORS.shallow) },
|
|
7737
|
+
uDeepColor: { value: new Color(DEFAULT_COLORS.deep) },
|
|
7738
|
+
uFoamColor: { value: new Color(FOAM_COLOR) },
|
|
7739
|
+
uSkyColor: { value: new Color(DEFAULT_COLORS.sky) },
|
|
7740
|
+
uHorizonColor: { value: new Color(DEFAULT_COLORS.horizon) },
|
|
7741
|
+
uBankColor: { value: new Color(DEFAULT_COLORS.bank) },
|
|
7742
|
+
uSunDirection: { value: new Vector3(.5, .8, .3) },
|
|
7743
|
+
uSunColor: { value: new Color(this.sunColor) },
|
|
7744
|
+
uSunIntensity: { value: this.sunIntensity },
|
|
7745
|
+
uMaxDepth: { value: this.depth },
|
|
7746
|
+
uAbsorption: { value: this.absorption },
|
|
7747
|
+
uOpacity: { value: this.opacity },
|
|
7748
|
+
uFoam: { value: this.foam },
|
|
7749
|
+
uRippleScale: { value: LOOK.rippleScale },
|
|
7750
|
+
uRippleStrength: { value: LOOK.rippleStrength * this.ripples },
|
|
7751
|
+
uFlowRate: { value: LOOK.flowRate },
|
|
7752
|
+
uSparkle: { value: this.ripples },
|
|
7753
|
+
uFresnelScale: { value: LOOK.fresnelScale },
|
|
7754
|
+
uStandingAmp: { value: LOOK.standingAmp },
|
|
7755
|
+
uGlideAmp: { value: LOOK.glideAmp }
|
|
7756
|
+
},
|
|
7757
|
+
transparent: true,
|
|
7758
|
+
depthTest: true,
|
|
7759
|
+
depthWrite: false,
|
|
7760
|
+
polygonOffset: true,
|
|
7761
|
+
polygonOffsetFactor: -2,
|
|
7762
|
+
polygonOffsetUnits: -4,
|
|
7763
|
+
side: DoubleSide
|
|
7764
|
+
});
|
|
7765
|
+
}
|
|
7766
|
+
syncUniforms(mesh) {
|
|
7767
|
+
const u = mesh.material.uniforms;
|
|
7768
|
+
const set = (key, value) => {
|
|
7769
|
+
const slot = u[key];
|
|
7770
|
+
if (slot) slot.value = value;
|
|
7771
|
+
};
|
|
7772
|
+
const tint = (key, hex) => {
|
|
7773
|
+
(u[key]?.value)?.set(hex);
|
|
7774
|
+
};
|
|
7775
|
+
const colors = this.colors;
|
|
7776
|
+
set("uTime", this.time);
|
|
7777
|
+
tint("uShallowColor", colors.shallow ?? DEFAULT_COLORS.shallow);
|
|
7778
|
+
tint("uDeepColor", colors.deep ?? DEFAULT_COLORS.deep);
|
|
7779
|
+
tint("uSkyColor", colors.sky ?? DEFAULT_COLORS.sky);
|
|
7780
|
+
tint("uHorizonColor", colors.horizon ?? DEFAULT_COLORS.horizon);
|
|
7781
|
+
tint("uBankColor", colors.bank ?? DEFAULT_COLORS.bank);
|
|
7782
|
+
tint("uSunColor", this.sunColor);
|
|
7783
|
+
(u.uSunDirection?.value)?.set(this.sunDirection[0] ?? .5, this.sunDirection[1] ?? .8, this.sunDirection[2] ?? .3);
|
|
7784
|
+
set("uSunIntensity", this.sunIntensity);
|
|
7785
|
+
set("uMaxDepth", this.depth);
|
|
7786
|
+
set("uAbsorption", this.absorption);
|
|
7787
|
+
set("uOpacity", this.opacity);
|
|
7788
|
+
set("uFoam", this.foam);
|
|
7789
|
+
set("uRippleStrength", LOOK.rippleStrength * this.ripples);
|
|
7790
|
+
set("uSparkle", this.ripples);
|
|
7791
|
+
}
|
|
7792
|
+
/**
|
|
7793
|
+
* Drag every body floating in the channel toward the water's own velocity.
|
|
7794
|
+
* Drag rather than thrust: a raft accelerates until it matches the current
|
|
7795
|
+
* and then coasts, and a swimmer can still fight across a slow ford.
|
|
7796
|
+
*/
|
|
7797
|
+
sweepBodies(dt) {
|
|
7798
|
+
let top = this;
|
|
7799
|
+
while (top.parent) top = top.parent;
|
|
7800
|
+
const bodies = this._bodies;
|
|
7801
|
+
bodies.length = 0;
|
|
7802
|
+
collectBodies$1(top, bodies);
|
|
7803
|
+
if (bodies.length === 0) return;
|
|
7804
|
+
const [wx, wy, wz] = this.world;
|
|
7805
|
+
for (const body of bodies) {
|
|
7806
|
+
const p = worldXZY(body);
|
|
7807
|
+
const hit = projectToRiver(this.rings, p[0] - wx, p[2] - wz);
|
|
7808
|
+
if (!hit?.inside) continue;
|
|
7809
|
+
const surfaceY = hit.surfaceY + wy;
|
|
7810
|
+
const feet = p[1] - colliderFootDrop(body.collider);
|
|
7811
|
+
if (feet > surfaceY + CURRENT_SURFACE_SLACK) continue;
|
|
7812
|
+
if (feet < surfaceY - this.depth - CURRENT_UNDER_SLACK) continue;
|
|
7813
|
+
const vel = bodyVelocity$1(body);
|
|
7814
|
+
if (!vel) continue;
|
|
7815
|
+
const gain = Math.min(1, this.flowForce * CURRENT_DRAG * dt);
|
|
7816
|
+
const dvx = (hit.dirX * hit.speed - (vel[0] ?? 0)) * gain;
|
|
7817
|
+
const dvz = (hit.dirZ * hit.speed - (vel[2] ?? 0)) * gain;
|
|
7818
|
+
if (body instanceof RigidBody3D) {
|
|
7819
|
+
const mass = body.mass || 1;
|
|
7820
|
+
body.applyImpulse([
|
|
7821
|
+
dvx * mass,
|
|
7822
|
+
0,
|
|
7823
|
+
dvz * mass
|
|
7824
|
+
]);
|
|
7825
|
+
} else {
|
|
7826
|
+
vel[0] = (vel[0] ?? 0) + dvx;
|
|
7827
|
+
vel[2] = (vel[2] ?? 0) + dvz;
|
|
7828
|
+
}
|
|
7829
|
+
}
|
|
7830
|
+
}
|
|
7831
|
+
};
|
|
7832
|
+
/** Bodies a current can carry: kinematic characters and dynamic rigid bodies. */
|
|
7833
|
+
function collectBodies$1(node, out) {
|
|
7834
|
+
if (node instanceof CharacterBody3D || node instanceof RigidBody3D) out.push(node);
|
|
7835
|
+
for (const child of node.children) collectBodies$1(child, out);
|
|
7836
|
+
}
|
|
7837
|
+
/** A body's velocity vector — kinematic `velocity` or dynamic `linearVelocity`. */
|
|
7838
|
+
function bodyVelocity$1(body) {
|
|
7839
|
+
return body.velocity ?? body.linearVelocity;
|
|
7840
|
+
}
|
|
7841
|
+
//#endregion
|
|
6675
7842
|
//#region src/3d/nodes/trail-3d.ts
|
|
6676
7843
|
const MAX_POINTS = 128;
|
|
6677
7844
|
/**
|
|
@@ -9948,121 +11115,6 @@ function sphereInCameraFrustum(camera, center, radius) {
|
|
|
9948
11115
|
return scratchFrustum.intersectsSphere(scratchSphere);
|
|
9949
11116
|
}
|
|
9950
11117
|
//#endregion
|
|
9951
|
-
//#region src/3d/water/interaction.ts
|
|
9952
|
-
/**
|
|
9953
|
-
* Water3D character interaction — pure functions, no three, node-env testable.
|
|
9954
|
-
*
|
|
9955
|
-
* The node samples physics bodies each update, detects waterline crossings
|
|
9956
|
-
* (`detectCrossings`), and converts them into ripple impulses in a FIXED-SIZE
|
|
9957
|
-
* ring buffer that mirrors the shader's `uRipples[8]` uniform array
|
|
9958
|
-
* (`pushRipple` / `ageRipples`). `rippleHeight` is the exact ring-wave shape
|
|
9959
|
-
* the vertex shader evaluates — `quality: 'simple'` runs it on the CPU.
|
|
9960
|
-
*/
|
|
9961
|
-
/** Ring-buffer capacity — MUST match `uRipples[8]` in the water shaders. */
|
|
9962
|
-
const WATER_MAX_RIPPLES = 8;
|
|
9963
|
-
/**
|
|
9964
|
-
* Distance from a body's ORIGIN down to its collider's lowest point. Water
|
|
9965
|
-
* interaction adds this to the waterline so a body counts as touching the water
|
|
9966
|
-
* when its FEET dip below the surface — not only when its centre does (a wading
|
|
9967
|
-
* character whose origin rides ~1 m above its feet would otherwise never splash).
|
|
9968
|
-
* 0 for a missing/empty/unsized collider (origin == feet).
|
|
9969
|
-
*/
|
|
9970
|
-
function colliderFootDrop(collider) {
|
|
9971
|
-
if (!collider || typeof collider !== "object") return 0;
|
|
9972
|
-
switch (collider.shape) {
|
|
9973
|
-
case "capsule": return (collider.height ?? 0) / 2 + (collider.radius ?? 0);
|
|
9974
|
-
case "sphere": return collider.radius ?? 0;
|
|
9975
|
-
case "box": return (collider.size?.[1] ?? 0) / 2;
|
|
9976
|
-
default: return 0;
|
|
9977
|
-
}
|
|
9978
|
-
}
|
|
9979
|
-
/**
|
|
9980
|
-
* Foam strength (0..1) for a body ENTERING the water, from its DOWNWARD speed:
|
|
9981
|
-
* a gentle wade-in froths a little; a hard plunge froths fully. Clamped at
|
|
9982
|
-
* `FOAM_FULL_SPEED`.
|
|
9983
|
-
*/
|
|
9984
|
-
function entryFoam(fallSpeed) {
|
|
9985
|
-
return .55 + .45 * (Math.min(Math.max(fallSpeed, 0), 12) / 12);
|
|
9986
|
-
}
|
|
9987
|
-
/**
|
|
9988
|
-
* Diff body positions against the waterline. A body enters when `y` drops
|
|
9989
|
-
* below its waterline and exits when it rises above `waterline + hysteresis`
|
|
9990
|
-
* (the band stops surface-bobbing bodies from flickering entered/exited).
|
|
9991
|
-
* Bodies missing from `samples` (despawned mid-water) are dropped WITHOUT an
|
|
9992
|
-
* `exited` event — their splash position no longer exists.
|
|
9993
|
-
*/
|
|
9994
|
-
function detectCrossings(prev, samples, hysteresis = 0) {
|
|
9995
|
-
const entered = [];
|
|
9996
|
-
const exited = [];
|
|
9997
|
-
const inWater = /* @__PURE__ */ new Set();
|
|
9998
|
-
for (const s of samples) {
|
|
9999
|
-
const wasIn = prev.has(s.id);
|
|
10000
|
-
if (wasIn ? s.y <= s.waterline + hysteresis : s.y < s.waterline) {
|
|
10001
|
-
inWater.add(s.id);
|
|
10002
|
-
if (!wasIn) entered.push(s);
|
|
10003
|
-
} else if (wasIn) exited.push(s);
|
|
10004
|
-
}
|
|
10005
|
-
return {
|
|
10006
|
-
entered,
|
|
10007
|
-
exited,
|
|
10008
|
-
inWater
|
|
10009
|
-
};
|
|
10010
|
-
}
|
|
10011
|
-
/** Append a ripple at age 0; when the buffer is full, evict the OLDEST.
|
|
10012
|
-
* `foam` (0..1) tags how much surface froth the shaders paint around it. */
|
|
10013
|
-
function pushRipple(ripples, x, z, amp, foam = 0, max = 8) {
|
|
10014
|
-
if (ripples.length >= max) {
|
|
10015
|
-
let oldest = 0;
|
|
10016
|
-
for (let i = 1; i < ripples.length; i++) if (ripples[i].age > ripples[oldest].age) oldest = i;
|
|
10017
|
-
ripples.splice(oldest, 1);
|
|
10018
|
-
}
|
|
10019
|
-
ripples.push({
|
|
10020
|
-
x,
|
|
10021
|
-
z,
|
|
10022
|
-
age: 0,
|
|
10023
|
-
amp,
|
|
10024
|
-
foam
|
|
10025
|
-
});
|
|
10026
|
-
}
|
|
10027
|
-
/** Advance ages by `dt` and drop ripples decayed past `maxAge` (in place). */
|
|
10028
|
-
function ageRipples(ripples, dt, maxAge = 3) {
|
|
10029
|
-
for (let i = ripples.length - 1; i >= 0; i--) {
|
|
10030
|
-
const r = ripples[i];
|
|
10031
|
-
r.age += dt;
|
|
10032
|
-
if (r.age >= maxAge) ripples.splice(i, 1);
|
|
10033
|
-
}
|
|
10034
|
-
}
|
|
10035
|
-
/** The ring-wave height contribution of one ripple at world (x, z). */
|
|
10036
|
-
function rippleHeight(x, z, ripple) {
|
|
10037
|
-
const dx = x - ripple.x;
|
|
10038
|
-
const dz = z - ripple.z;
|
|
10039
|
-
const r = Math.sqrt(dx * dx + dz * dz);
|
|
10040
|
-
return ripple.amp * Math.sin(6 * r - 8 * ripple.age) * Math.exp(-1.1 * ripple.age) * Math.exp(-.15 * r * r);
|
|
10041
|
-
}
|
|
10042
|
-
/** Sum of every live ripple's height at world (x, z) — the CPU water path. */
|
|
10043
|
-
function ripplesHeightAt(x, z, ripples) {
|
|
10044
|
-
let h = 0;
|
|
10045
|
-
for (const r of ripples) h += rippleHeight(x, z, r);
|
|
10046
|
-
return h;
|
|
10047
|
-
}
|
|
10048
|
-
/**
|
|
10049
|
-
* Accumulate per-body bob timers for bodies floating IN water; returns the
|
|
10050
|
-
* ids due for a gentle bob impulse this update (timer wraps, cadence keeps).
|
|
10051
|
-
* Timers of bodies that left the water are forgotten.
|
|
10052
|
-
*/
|
|
10053
|
-
function stepBobTimers(timers, inWater, dt, interval) {
|
|
10054
|
-
const due = [];
|
|
10055
|
-
for (const id of timers.keys()) if (!inWater.has(id)) timers.delete(id);
|
|
10056
|
-
for (const id of inWater) {
|
|
10057
|
-
const t = (timers.get(id) ?? 0) + dt;
|
|
10058
|
-
if (t >= interval) {
|
|
10059
|
-
due.push(id);
|
|
10060
|
-
timers.set(id, t - interval);
|
|
10061
|
-
} else timers.set(id, t);
|
|
10062
|
-
}
|
|
10063
|
-
return due;
|
|
10064
|
-
}
|
|
10065
|
-
//#endregion
|
|
10066
11118
|
//#region src/3d/water/lake.ts
|
|
10067
11119
|
/**
|
|
10068
11120
|
* The Water3D 'simple' quality lake shader — the CHEAP good-looking path.
|
|
@@ -10786,6 +11838,16 @@ varying vec3 vWorldPosition;
|
|
|
10786
11838
|
varying vec4 vScreenPosition;
|
|
10787
11839
|
|
|
10788
11840
|
uniform samplerCube uEnvironmentMap;
|
|
11841
|
+
// incanto MIRROR: a planar reflection rendered from the camera mirrored across
|
|
11842
|
+
// the water plane. A water-centered cube map can only carry DISTANT content —
|
|
11843
|
+
// the shoreline trees, the boat, the player standing at the edge are exactly
|
|
11844
|
+
// what a still lake is supposed to show, and only a planar pass has them.
|
|
11845
|
+
// uMirrorMatrix projects a world position into that render's UV, so the sample
|
|
11846
|
+
// is self-consistent no matter where the fragment sits on the surface.
|
|
11847
|
+
uniform sampler2D uMirrorMap;
|
|
11848
|
+
uniform mat4 uMirrorMatrix;
|
|
11849
|
+
uniform float uUseMirror;
|
|
11850
|
+
uniform float uMirrorDistort;
|
|
10789
11851
|
|
|
10790
11852
|
// Noise function (for foam patterns)
|
|
10791
11853
|
float hash(vec2 p) {
|
|
@@ -11160,6 +12222,27 @@ void main() {
|
|
|
11160
12222
|
vec4 reflectionColor = textureCube(uEnvironmentMap, reflectedDirection);
|
|
11161
12223
|
reflectionColor.rgb = min(reflectionColor.rgb, vec3(2.5));
|
|
11162
12224
|
|
|
12225
|
+
// incanto MIRROR: where the planar pass has data it REPLACES the cube — it
|
|
12226
|
+
// is the same sky plus everything the cube map is blind to. Rays that leave
|
|
12227
|
+
// the screen have no mirror sample, so the border fade hands those back to
|
|
12228
|
+
// the cube instead of clamping a stretched edge texel across the surface.
|
|
12229
|
+
if (uUseMirror > 0.5) {
|
|
12230
|
+
vec4 mirrorClip = uMirrorMatrix * vec4(vWorldPosition, 1.0);
|
|
12231
|
+
vec2 mirrorUv = mirrorClip.xy / max(mirrorClip.w, 1e-4);
|
|
12232
|
+
// the ripple tilt bends the reflected ray: offset the sample by how far
|
|
12233
|
+
// the wavelet normal leans off flat, damped with distance so the far field
|
|
12234
|
+
// (many wavelets per pixel) stays a clean mirror instead of a jitter field
|
|
12235
|
+
vec2 lean = (fresnelNormal.xz - geoNormal.xz) * uMirrorDistort;
|
|
12236
|
+
mirrorUv += lean * (1.0 - smoothstep(20.0, 90.0, camDist));
|
|
12237
|
+
vec2 edge = smoothstep(vec2(0.0), vec2(0.06), mirrorUv) *
|
|
12238
|
+
(1.0 - smoothstep(vec2(0.94), vec2(1.0), mirrorUv));
|
|
12239
|
+
float valid = edge.x * edge.y * step(0.0, mirrorClip.w);
|
|
12240
|
+
if (valid > 0.0) {
|
|
12241
|
+
vec3 mirrored = min(texture2D(uMirrorMap, mirrorUv).rgb, vec3(2.5));
|
|
12242
|
+
reflectionColor.rgb = mix(reflectionColor.rgb, mirrored, valid);
|
|
12243
|
+
}
|
|
12244
|
+
}
|
|
12245
|
+
|
|
11163
12246
|
// Calculate fresnel effect
|
|
11164
12247
|
// incanto: the source dotted the camera→surface direction against the
|
|
11165
12248
|
// up-facing normal — NEGATIVE at every above-water angle, so the clamp
|
|
@@ -11836,6 +12919,10 @@ const WAVE_WORLD_REF = 128;
|
|
|
11836
12919
|
const AMPLITUDE_PER_WAVE_HEIGHT = REFERENCE_AMPLITUDE / REFERENCE_WAVE_HEIGHT;
|
|
11837
12920
|
/** Cube-map reflection resolution (source: reflectionQuality = 256). */
|
|
11838
12921
|
const REFLECTION_RESOLUTION = 256;
|
|
12922
|
+
/** Planar-mirror target size cap (per side) — half the drawing buffer, clamped. */
|
|
12923
|
+
const MIRROR_PASS_MAX = 1024;
|
|
12924
|
+
/** How far a wavelet's tilt slides the mirror sample, in UV per unit of normal lean. */
|
|
12925
|
+
const MIRROR_DISTORT = .06;
|
|
11839
12926
|
/** Scene pre-pass (depth for absorption/foam + color grab for refraction):
|
|
11840
12927
|
* half the drawing buffer, clamped — ONE extra scene render per frame. */
|
|
11841
12928
|
const SCENE_PASS_MAX = 1024;
|
|
@@ -11921,6 +13008,7 @@ var Water3D = class Water3D extends Node3D {
|
|
|
11921
13008
|
colors: { default: {} },
|
|
11922
13009
|
reflection: { default: true },
|
|
11923
13010
|
reflectionInterval: { default: 1e3 },
|
|
13011
|
+
mirror: { default: false },
|
|
11924
13012
|
foam: { default: true },
|
|
11925
13013
|
interaction: { default: true },
|
|
11926
13014
|
splash: { default: true },
|
|
@@ -11963,6 +13051,14 @@ var Water3D = class Water3D extends Node3D {
|
|
|
11963
13051
|
colors = {};
|
|
11964
13052
|
/** CubeCamera reflections (fancy only; needs a live renderer). */
|
|
11965
13053
|
reflection = true;
|
|
13054
|
+
/**
|
|
13055
|
+
* TRUE planar reflection: the scene re-rendered from the camera mirrored
|
|
13056
|
+
* across this surface, sampled per fragment. A cube map can only carry
|
|
13057
|
+
* distant content — the shoreline trees, the moored boat, the player at the
|
|
13058
|
+
* water's edge only appear in the water with this on. Costs one extra scene
|
|
13059
|
+
* render per frame, so it is off by default; a still lake earns it.
|
|
13060
|
+
*/
|
|
13061
|
+
mirror = false;
|
|
11966
13062
|
/** ms between reflection (and foam depth) re-renders. */
|
|
11967
13063
|
reflectionInterval = 1e3;
|
|
11968
13064
|
/** Depth-texture shoreline foam — rides the always-on fancy scene pass. */
|
|
@@ -12122,6 +13218,9 @@ var Water3D = class Water3D extends Node3D {
|
|
|
12122
13218
|
lastReflectionAt = 0;
|
|
12123
13219
|
/** Per-frame scene pre-pass: depth (absorption/foam) + color grab (refraction). */
|
|
12124
13220
|
scenePassTarget = null;
|
|
13221
|
+
mirrorTarget = null;
|
|
13222
|
+
mirrorCamera = new PerspectiveCamera();
|
|
13223
|
+
mirrorMatrix = new Matrix4();
|
|
12125
13224
|
_createObject3D() {
|
|
12126
13225
|
return new Mesh();
|
|
12127
13226
|
}
|
|
@@ -12154,6 +13253,7 @@ var Water3D = class Water3D extends Node3D {
|
|
|
12154
13253
|
const u = material.uniforms;
|
|
12155
13254
|
if (u.uUseSceneDepth) u.uUseSceneDepth.value = 0;
|
|
12156
13255
|
if (u.uUseRefraction) u.uUseRefraction.value = 0;
|
|
13256
|
+
if (u.uUseMirror) u.uUseMirror.value = 0;
|
|
12157
13257
|
}
|
|
12158
13258
|
return;
|
|
12159
13259
|
}
|
|
@@ -12189,6 +13289,57 @@ var Water3D = class Water3D extends Node3D {
|
|
|
12189
13289
|
u.uUseEnvironmentMap.value = 1;
|
|
12190
13290
|
}
|
|
12191
13291
|
}
|
|
13292
|
+
if (this.mirror && u.uMirrorMap && u.uMirrorMatrix && u.uUseMirror) {
|
|
13293
|
+
ctx.gl.getDrawingBufferSize(drawingBufferScratch);
|
|
13294
|
+
const mirrorW = Math.max(1, Math.min(Math.round(drawingBufferScratch.x / 2), MIRROR_PASS_MAX));
|
|
13295
|
+
const mirrorH = Math.max(1, Math.min(Math.round(drawingBufferScratch.y / 2), MIRROR_PASS_MAX));
|
|
13296
|
+
if (this.mirrorTarget && (this.mirrorTarget.width !== mirrorW || this.mirrorTarget.height !== mirrorH)) {
|
|
13297
|
+
this.mirrorTarget.dispose();
|
|
13298
|
+
this.mirrorTarget = null;
|
|
13299
|
+
}
|
|
13300
|
+
if (!this.mirrorTarget) this.mirrorTarget = new WebGLRenderTarget(mirrorW, mirrorH, {
|
|
13301
|
+
depthBuffer: true,
|
|
13302
|
+
stencilBuffer: false,
|
|
13303
|
+
type: HalfFloatType
|
|
13304
|
+
});
|
|
13305
|
+
const surfaceY = worldTranslationOf(this).y;
|
|
13306
|
+
const cam = ctx.camera;
|
|
13307
|
+
cam.getWorldPosition(mirrorEyeScratch);
|
|
13308
|
+
cam.getWorldQuaternion(mirrorQuatScratch);
|
|
13309
|
+
mirrorTargetScratch.set(0, 0, -1).applyQuaternion(mirrorQuatScratch).add(mirrorEyeScratch);
|
|
13310
|
+
mirrorUpScratch.set(0, 1, 0).applyQuaternion(mirrorQuatScratch);
|
|
13311
|
+
mirrorEyeScratch.y = 2 * surfaceY - mirrorEyeScratch.y;
|
|
13312
|
+
mirrorTargetScratch.y = 2 * surfaceY - mirrorTargetScratch.y;
|
|
13313
|
+
mirrorUpScratch.y = -mirrorUpScratch.y;
|
|
13314
|
+
const mirrorCam = this.mirrorCamera;
|
|
13315
|
+
mirrorCam.fov = cam.fov ?? 50;
|
|
13316
|
+
mirrorCam.aspect = cam.aspect ?? mirrorW / mirrorH;
|
|
13317
|
+
mirrorCam.near = cam.near ?? .1;
|
|
13318
|
+
mirrorCam.far = cam.far ?? 1e3;
|
|
13319
|
+
mirrorCam.position.copy(mirrorEyeScratch);
|
|
13320
|
+
mirrorCam.up.copy(mirrorUpScratch);
|
|
13321
|
+
mirrorCam.lookAt(mirrorTargetScratch);
|
|
13322
|
+
mirrorCam.updateMatrixWorld(true);
|
|
13323
|
+
mirrorCam.updateProjectionMatrix();
|
|
13324
|
+
this.mirrorMatrix.set(.5, 0, 0, .5, 0, .5, 0, .5, 0, 0, .5, .5, 0, 0, 0, 1);
|
|
13325
|
+
this.mirrorMatrix.multiply(mirrorCam.projectionMatrix);
|
|
13326
|
+
this.mirrorMatrix.multiply(mirrorCam.matrixWorldInverse);
|
|
13327
|
+
const prevMirrorTarget = ctx.gl.getRenderTarget();
|
|
13328
|
+
const prevMirrorClip = ctx.gl.clippingPlanes;
|
|
13329
|
+
const wasMirrorVisible = mesh.visible;
|
|
13330
|
+
mesh.visible = false;
|
|
13331
|
+
mirrorClipPlane.constant = -(surfaceY - WATERLINE_CLIP_SLACK);
|
|
13332
|
+
ctx.gl.clippingPlanes = mirrorClipPlanes;
|
|
13333
|
+
ctx.gl.setRenderTarget(this.mirrorTarget);
|
|
13334
|
+
ctx.gl.clear(true, true, false);
|
|
13335
|
+
ctx.gl.render(ctx.scene, mirrorCam);
|
|
13336
|
+
ctx.gl.setRenderTarget(prevMirrorTarget);
|
|
13337
|
+
ctx.gl.clippingPlanes = prevMirrorClip;
|
|
13338
|
+
mesh.visible = wasMirrorVisible;
|
|
13339
|
+
u.uMirrorMap.value = this.mirrorTarget.texture;
|
|
13340
|
+
u.uMirrorMatrix.value.copy(this.mirrorMatrix);
|
|
13341
|
+
u.uUseMirror.value = 1;
|
|
13342
|
+
} else if (u.uUseMirror) u.uUseMirror.value = 0;
|
|
12192
13343
|
ctx.gl.getDrawingBufferSize(drawingBufferScratch);
|
|
12193
13344
|
const passW = Math.max(1, Math.min(Math.round(drawingBufferScratch.x / 2), SCENE_PASS_MAX));
|
|
12194
13345
|
const passH = Math.max(1, Math.min(Math.round(drawingBufferScratch.y / 2), SCENE_PASS_MAX));
|
|
@@ -12277,6 +13428,8 @@ var Water3D = class Water3D extends Node3D {
|
|
|
12277
13428
|
this.scenePassTarget?.depthTexture?.dispose();
|
|
12278
13429
|
this.scenePassTarget?.dispose();
|
|
12279
13430
|
this.scenePassTarget = null;
|
|
13431
|
+
this.mirrorTarget?.dispose();
|
|
13432
|
+
this.mirrorTarget = null;
|
|
12280
13433
|
super.free();
|
|
12281
13434
|
}
|
|
12282
13435
|
/** Swap geometry+material only when a surface-shaping prop changed. */
|
|
@@ -12310,6 +13463,10 @@ var Water3D = class Water3D extends Node3D {
|
|
|
12310
13463
|
uOpacity: { value: this.rp("opacity") },
|
|
12311
13464
|
uEnvironmentMap: { value: null },
|
|
12312
13465
|
uUseEnvironmentMap: { value: 0 },
|
|
13466
|
+
uMirrorMap: { value: null },
|
|
13467
|
+
uMirrorMatrix: { value: new Matrix4() },
|
|
13468
|
+
uUseMirror: { value: 0 },
|
|
13469
|
+
uMirrorDistort: { value: MIRROR_DISTORT },
|
|
12313
13470
|
uWavesAmplitude: { value: this.rp("waveHeight") * AMPLITUDE_PER_WAVE_HEIGHT },
|
|
12314
13471
|
uWavesSpeed: { value: FANCY_WAVE.speed },
|
|
12315
13472
|
uWavesFrequency: { value: FANCY_WAVE.frequency },
|
|
@@ -12664,6 +13821,13 @@ const underwaterCamScratch = new Vector3();
|
|
|
12664
13821
|
const UNDERWATER_MARGIN = 3;
|
|
12665
13822
|
/** Keep-below clip plane for the scene pre-pass (constant set per render):
|
|
12666
13823
|
* normal (0,−1,0) keeps y < constant — see the grab-clip note in _onRender3D. */
|
|
13824
|
+
const mirrorEyeScratch = new Vector3();
|
|
13825
|
+
const mirrorTargetScratch = new Vector3();
|
|
13826
|
+
const mirrorUpScratch = new Vector3();
|
|
13827
|
+
const mirrorQuatScratch = new Quaternion();
|
|
13828
|
+
/** Mirror pass: keep only what is ABOVE the waterline. */
|
|
13829
|
+
const mirrorClipPlane = new Plane(new Vector3(0, 1, 0), 0);
|
|
13830
|
+
const mirrorClipPlanes = [mirrorClipPlane];
|
|
12667
13831
|
const grabClipPlane = new Plane(new Vector3(0, -1, 0), 0);
|
|
12668
13832
|
const grabClipPlanes = [grabClipPlane];
|
|
12669
13833
|
/** Keep-above twin for the depth-completion pass: normal (0,1,0) keeps
|
|
@@ -12695,6 +13859,7 @@ function registerNodes3D() {
|
|
|
12695
13859
|
registerNode(BoneAttachment3D);
|
|
12696
13860
|
registerNode(BoneLookAt3D);
|
|
12697
13861
|
registerNode(InstancedMesh3D);
|
|
13862
|
+
registerNode(River3D);
|
|
12698
13863
|
registerNode(Terrain3D);
|
|
12699
13864
|
registerNode(Water3D);
|
|
12700
13865
|
registerNode(Foliage3D);
|
|
@@ -12710,4 +13875,4 @@ function registerNodes3D() {
|
|
|
12710
13875
|
registerNode(CharacterBody3D);
|
|
12711
13876
|
}
|
|
12712
13877
|
//#endregion
|
|
12713
|
-
export {
|
|
13878
|
+
export { TERRAIN_THEMES as A, QUARTER_PITCH as B, CharacterController3D as C, Billboard3D as D, BoneAttachment3D as E, PhysicsBody3D as F, keyboardIntensity as H, RigidBody3D as I, StaticBody3D as L, Joint3D as M, Area3D as N, Terrain3D as O, CharacterBody3D as P, Node3D as R, FLOWER_VARIETIES as S, BoneLookAt3D as T, movementState as U, cameraRelative as V, rigPose as W, MeshInstance3D as _, VoxelGrid3D as a, Flowers3D as b, River3D as c, Particles3D as d, ModelInstance3D as f, InstancedMesh3D as g, OmniLight3D as h, VOXEL_PALETTE as i, terrainThemeLayers as j, DEFAULT_TERRAIN_TEXTURE_BASE as k, traceDownhillPath as l, DirectionalLight3D as m, Water3D as n, Tree3D as o, LoftMesh3D as p, createCausticsQuad as r, Trail3D as s, registerNodes3D as t, WATER_MAX_RIPPLES as u, Foliage3D as v, Camera3D as w, resolveFlowerDensity as x, DENSITY_PRESETS as y, validateCollider3D as z };
|