sindicate 0.1.0 → 0.4.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/README.md +63 -2
- package/docs/api/README.md +28 -0
- package/docs/api/anim.md +125 -0
- package/docs/api/assets.md +208 -0
- package/docs/api/collision.md +101 -0
- package/docs/api/fbxloader.md +50 -0
- package/docs/api/grass.md +103 -0
- package/docs/api/input.md +164 -0
- package/docs/api/quality.md +159 -0
- package/docs/api/renderer.md +115 -0
- package/docs/api/retarget.md +149 -0
- package/docs/api/shatter.md +121 -0
- package/docs/api/utils-decals.md +188 -0
- package/docs/api/water.md +143 -0
- package/docs/api/wind-weather-uniforms.md +105 -0
- package/docs/contracts/render.md +32 -0
- package/docs/contracts/time-feel.md +52 -0
- package/package.json +1 -1
- package/src/core/anim.js +184 -0
- package/src/core/assets.js +596 -0
- package/src/core/engine.js +284 -0
- package/src/core/input/bindings.js +30 -0
- package/src/core/input/gamepad.js +92 -0
- package/src/core/input/index.js +62 -0
- package/src/core/input/keyboard.js +16 -0
- package/src/core/input/mouse.js +48 -0
- package/src/core/quality.js +5 -1
- package/src/core/renderer.js +104 -0
- package/src/core/retarget.js +282 -0
- package/src/index.js +19 -2
- package/src/systems/aim.js +114 -0
- package/src/systems/campfire.js +150 -0
- package/src/systems/climbing.js +136 -0
- package/src/systems/deadeye.js +332 -0
- package/src/systems/encounters.js +123 -0
- package/src/systems/entity.js +524 -0
- package/src/systems/fistCurl.js +38 -0
- package/src/systems/footsteps.js +161 -0
- package/src/systems/glass.js +67 -0
- package/src/systems/herd.js +277 -0
- package/src/systems/horse.js +361 -0
- package/src/systems/mountedTraveller.js +518 -0
- package/src/systems/nav.js +343 -0
- package/src/systems/npc.js +880 -0
- package/src/systems/pathFollow.js +107 -0
- package/src/systems/platform.js +484 -0
- package/src/systems/riding.js +580 -0
- package/src/systems/seatedRider.js +396 -0
- package/src/systems/skybirds.js +129 -0
- package/src/systems/trainCamera.js +414 -0
- package/src/systems/traveller.js +254 -0
- package/src/systems/tumbleweeds.js +92 -0
- package/src/systems/vat.js +327 -0
- package/src/systems/vfx.js +472 -0
- package/src/world/blobShadows.js +109 -0
- package/src/world/clouds.js +61 -0
- package/src/world/flora.js +87 -0
- package/src/world/footprints.js +117 -0
- package/src/world/lampField.js +58 -0
- package/src/world/lampGlow.js +92 -0
- package/src/world/scatter.js +1077 -0
- package/src/world/sky.js +363 -0
- package/src/world/tileManager.js +197 -0
- package/src/world/walkHumps.js +74 -0
- package/src/world/weather.js +312 -0
package/src/world/sky.js
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
// Dynamic sky: gradient dome, sun/moon orbit, stars, fog and light color
|
|
2
|
+
// driven by a 24h clock. Also tints toward dread inside Gravemoor.
|
|
3
|
+
import * as THREE from 'three/webgpu';
|
|
4
|
+
import { clamp, lerp, smoothstep } from '../core/utils.js';
|
|
5
|
+
import { getQuality } from '../core/quality.js';
|
|
6
|
+
import { uDayness, uSkyCol, uHorizonCol, uSunDir, uCloudiness, uWindDir, uWindStr } from './weatherUniforms.js';
|
|
7
|
+
import { buildCloudMaterial } from './clouds.js';
|
|
8
|
+
|
|
9
|
+
const DAY = {
|
|
10
|
+
sky: new THREE.Color(0x7fb2e5), horizon: new THREE.Color(0xdfe9d8),
|
|
11
|
+
fog: new THREE.Color(0xc3d4dd), sun: new THREE.Color(0xfff1d6), sunI: 2.6, ambI: 0.85,
|
|
12
|
+
};
|
|
13
|
+
const DUSK = {
|
|
14
|
+
sky: new THREE.Color(0x4a4a7a), horizon: new THREE.Color(0xf2a05a),
|
|
15
|
+
fog: new THREE.Color(0xb38a6a), sun: new THREE.Color(0xffb070), sunI: 1.5, ambI: 0.5,
|
|
16
|
+
};
|
|
17
|
+
const NIGHT = {
|
|
18
|
+
// fog/horizon must stay DARKER than moonlit ground or distance glows brighter than
|
|
19
|
+
// the foreground (fog is applied post-lighting, so it wins at range)
|
|
20
|
+
sky: new THREE.Color(0x0b1026), horizon: new THREE.Color(0x121828),
|
|
21
|
+
fog: new THREE.Color(0x0b101e), sun: new THREE.Color(0x9fb4e0), sunI: 2.6, ambI: 0.8,
|
|
22
|
+
};
|
|
23
|
+
const MOOR_TINT = new THREE.Color(0x5a6358);
|
|
24
|
+
// reused every frame in update() to avoid per-frame GC churn
|
|
25
|
+
const WHITE = new THREE.Color(0xffffff);
|
|
26
|
+
const STORM_CLOUD = new THREE.Color(0x49505b); // heavy slate deck at full weather
|
|
27
|
+
const _grey = new THREE.Color(); // scratch for the overcast desaturation
|
|
28
|
+
const _cloudM = new THREE.Matrix4(), _cloudQ = new THREE.Quaternion(), _cloudQ2 = new THREE.Quaternion(), _cloudV = new THREE.Vector3(), _cloudS = new THREE.Vector3();
|
|
29
|
+
const _cloudDir = new THREE.Vector3(), _cloudZ = new THREE.Vector3(0, 0, 1);
|
|
30
|
+
const _cloudUp = new THREE.Vector3(0, 1, 0), _cloudRight = new THREE.Vector3(), _cloudUp2 = new THREE.Vector3(), _cloudB = new THREE.Matrix4();
|
|
31
|
+
const CLOUD_MAXR = 5.5; // the cloud field's horizontal half-extent in sky units (h≈1); past it a cloud re-enters upwind
|
|
32
|
+
const _sunDir = new THREE.Vector3();
|
|
33
|
+
const _moonDir = new THREE.Vector3();
|
|
34
|
+
|
|
35
|
+
function blendPhase(hour, a, b, out, t) {
|
|
36
|
+
out.sky.copy(a.sky).lerp(b.sky, t);
|
|
37
|
+
out.horizon.copy(a.horizon).lerp(b.horizon, t);
|
|
38
|
+
out.fog.copy(a.fog).lerp(b.fog, t);
|
|
39
|
+
out.sun.copy(a.sun).lerp(b.sun, t);
|
|
40
|
+
out.sunI = lerp(a.sunI, b.sunI, t);
|
|
41
|
+
out.ambI = lerp(a.ambI, b.ambI, t);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class Sky {
|
|
45
|
+
constructor(scene) {
|
|
46
|
+
this.scene = scene;
|
|
47
|
+
this.hour = 10; // start mid-morning
|
|
48
|
+
this.speed = 24 / 900; // game-hours per real second (15-minute full day)
|
|
49
|
+
|
|
50
|
+
// THE SKY MUST FIT INSIDE THE CAMERA FAR PLANE. The dome/discs/stars were authored at
|
|
51
|
+
// 320-340m — beyond LOW's cameraFar (200m) the whole celestial rig was frustum-clipped,
|
|
52
|
+
// leaving flat fog only ("sky is just misty… no sun or moon anymore"). Scale everything
|
|
53
|
+
// to the active preset's far plane instead.
|
|
54
|
+
const q = getQuality();
|
|
55
|
+
this.skyR = Math.min(330, (q.cameraFar ?? 800) * 0.85);
|
|
56
|
+
|
|
57
|
+
// gradient dome via vertex colors on an inverted sphere
|
|
58
|
+
const geo = new THREE.SphereGeometry(340, 32, 16);
|
|
59
|
+
const colors = new Float32Array(geo.attributes.position.count * 3);
|
|
60
|
+
geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
|
61
|
+
this.domeGeo = geo;
|
|
62
|
+
this.dome = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({
|
|
63
|
+
vertexColors: true, side: THREE.BackSide, fog: false, depthWrite: false,
|
|
64
|
+
}));
|
|
65
|
+
this.dome.scale.setScalar(this.skyR / 340);
|
|
66
|
+
this.dome.renderOrder = -10;
|
|
67
|
+
this.dome.frustumCulled = false;
|
|
68
|
+
scene.add(this.dome);
|
|
69
|
+
|
|
70
|
+
// Clear colour behind everything. The dome only spans 340m around the player,
|
|
71
|
+
// but the terrain reaches 360m — past that edge there's no dome, so without this
|
|
72
|
+
// the bare clear colour (white/black) shows as "the end of the world". Tracking
|
|
73
|
+
// the fog colour each frame makes that gap read as more sky/haze instead.
|
|
74
|
+
scene.background = new THREE.Color(0xc3d4dd);
|
|
75
|
+
|
|
76
|
+
// stars
|
|
77
|
+
const starGeo = new THREE.BufferGeometry();
|
|
78
|
+
const n = 700, sp = new Float32Array(n * 3);
|
|
79
|
+
for (let i = 0; i < n; i++) {
|
|
80
|
+
const v = new THREE.Vector3().randomDirection();
|
|
81
|
+
v.y = Math.abs(v.y) * 0.9 + 0.1;
|
|
82
|
+
v.multiplyScalar(330);
|
|
83
|
+
sp.set([v.x, v.y, v.z], i * 3);
|
|
84
|
+
}
|
|
85
|
+
starGeo.setAttribute('position', new THREE.BufferAttribute(sp, 3));
|
|
86
|
+
this.stars = new THREE.Points(starGeo, new THREE.PointsMaterial({
|
|
87
|
+
color: 0xcfd8ff, size: 1.1, sizeAttenuation: false, transparent: true, opacity: 0, fog: false,
|
|
88
|
+
}));
|
|
89
|
+
this.stars.scale.setScalar(this.skyR / 340); // stars fit inside the far plane too
|
|
90
|
+
this.stars.frustumCulled = false;
|
|
91
|
+
scene.add(this.stars);
|
|
92
|
+
|
|
93
|
+
// lights — shadow settings fixed at construction (see quality.js note)
|
|
94
|
+
this.sun = new THREE.DirectionalLight(0xfff1d6, 2.6);
|
|
95
|
+
this.sun.castShadow = q.shadows;
|
|
96
|
+
this.sun.shadow.mapSize.set(q.shadowSize, q.shadowSize);
|
|
97
|
+
// 55m half-extent: the 2048 map covers a quarter of the old 90m area — the
|
|
98
|
+
// shadow pass encodes ~1/4 the casters per frame (measured ~2k casters at 90)
|
|
99
|
+
// and near shadows get 2.6x the texel density. Distant tree shadows fade out
|
|
100
|
+
// past ~55m, where they were soft mush anyway.
|
|
101
|
+
const s = 55;
|
|
102
|
+
// far was 400 — casters up to 400m along the sun ray were encoded into a 110m shadow patch, i.e.
|
|
103
|
+
// a lot of geometry too far to cast a visible shadow. 170 covers the on-screen near field.
|
|
104
|
+
Object.assign(this.sun.shadow.camera, { left: -s, right: s, top: s, bottom: -s, near: 1, far: 170 });
|
|
105
|
+
this.sun.shadow.bias = -0.0004;
|
|
106
|
+
scene.add(this.sun, this.sun.target);
|
|
107
|
+
|
|
108
|
+
this.hemi = new THREE.HemisphereLight(0xbdd3e8, 0xc9b18a, 0.85); // ground bounce = desert sand, not Albion grass
|
|
109
|
+
scene.add(this.hemi);
|
|
110
|
+
|
|
111
|
+
// sun/moon discs — the sun gets a warm gold core (0xfff4cc read as pure white through the
|
|
112
|
+
// ACES tonemapper: "i see sun but its white?") + a soft halo sprite behind it
|
|
113
|
+
// depthWrite:false + renderOrder -9 (right after the dome): the discs are physically NEARER
|
|
114
|
+
// than the horizon terrain (they must fit inside the far plane), so honest depth put the
|
|
115
|
+
// setting sun IN FRONT of the hills ("sinks into the earth"). Drawn early writing no depth,
|
|
116
|
+
// the terrain paints over them — the sun now sets behind the landscape like the dome does.
|
|
117
|
+
this.sunDisc = new THREE.Mesh(
|
|
118
|
+
new THREE.SphereGeometry(7, 12, 12),
|
|
119
|
+
new THREE.MeshBasicMaterial({ color: 0xffc84f, fog: false, depthWrite: false })
|
|
120
|
+
);
|
|
121
|
+
this.sunDisc.renderOrder = -9;
|
|
122
|
+
{
|
|
123
|
+
const hv = document.createElement('canvas'); hv.width = hv.height = 128;
|
|
124
|
+
const hx = hv.getContext('2d');
|
|
125
|
+
const hg = hx.createRadialGradient(64, 64, 4, 64, 64, 63);
|
|
126
|
+
hg.addColorStop(0, 'rgba(255,196,90,0.85)');
|
|
127
|
+
hg.addColorStop(0.35, 'rgba(255,180,70,0.32)');
|
|
128
|
+
hg.addColorStop(1, 'rgba(255,170,60,0)');
|
|
129
|
+
hx.fillStyle = hg; hx.fillRect(0, 0, 128, 128);
|
|
130
|
+
this.sunHalo = new THREE.Sprite(new THREE.SpriteMaterial({
|
|
131
|
+
map: new THREE.CanvasTexture(hv), transparent: true, depthWrite: false, fog: false,
|
|
132
|
+
}));
|
|
133
|
+
this.sunHalo.scale.setScalar(46);
|
|
134
|
+
scene.add(this.sunHalo);
|
|
135
|
+
}
|
|
136
|
+
this.moonColor = new THREE.Color(0xe8edff); // story hook: set toward 0xff4038 for a demon-moon period
|
|
137
|
+
this.moonDisc = new THREE.Mesh(
|
|
138
|
+
new THREE.SphereGeometry(8, 12, 12),
|
|
139
|
+
new THREE.MeshBasicMaterial({ color: 0xe8edff, fog: false, depthWrite: false })
|
|
140
|
+
);
|
|
141
|
+
this.moonDisc.renderOrder = -9;
|
|
142
|
+
scene.add(this.sunDisc, this.moonDisc);
|
|
143
|
+
|
|
144
|
+
scene.fog = new THREE.Fog(0xc3d4dd, 60, 300);
|
|
145
|
+
this._phase = { sky: new THREE.Color(), horizon: new THREE.Color(), fog: new THREE.Color(), sun: new THREE.Color(), sunI: 1, ambI: 1 };
|
|
146
|
+
|
|
147
|
+
// ---- clouds: PROCEDURAL FBM-noise puffs on dome-anchored quads (world/clouds.js) ----
|
|
148
|
+
// Each quad is one soft cumulus built from noise — no two alike, soft evolving edges, streamed
|
|
149
|
+
// downwind on the global wind — instead of the old single radial-blob sprite (52 identical flat discs).
|
|
150
|
+
// DOME-ANCHORED (a fixed radius/bearing that follows the player, like the dome/sun): the layer fills
|
|
151
|
+
// the WHOLE sky — horizon to zenith — and can never be far-plane-clipped. Tint + opacity are driven
|
|
152
|
+
// per phase/weather below (uCloudiness leads the rain: the deck thickens BEFORE precipitation).
|
|
153
|
+
{
|
|
154
|
+
const cloud = buildCloudMaterial();
|
|
155
|
+
this.cloudMat = cloud.material; this._cloudTint = cloud.tint; this._cloudOpacity = cloud.opacity;
|
|
156
|
+
const N = 40;
|
|
157
|
+
this.clouds = new THREE.InstancedMesh(new THREE.PlaneGeometry(1, 1), this.cloudMat, N);
|
|
158
|
+
this.clouds.frustumCulled = false;
|
|
159
|
+
this.clouds.renderOrder = -5; // over the dome, under everything else
|
|
160
|
+
this._aSun = new THREE.InstancedBufferAttribute(new Float32Array(N), 1); // per-cloud sun-side, for the directional shading
|
|
161
|
+
this.clouds.geometry.setAttribute('aSun', this._aSun);
|
|
162
|
+
// POSITIONS: a horizontal cloud FIELD overhead — each cloud has an (x,z) position in sky units at
|
|
163
|
+
// altitude h, and its view direction (where it sits on the dome) falls out of that, so clouds bunch
|
|
164
|
+
// toward the horizon and DRIFT WITH THE WIND (see _updateClouds). Clumped into clusters with clear
|
|
165
|
+
// sky between, a few solo strays — a real sky is patchy. Size/shape vary per cloud (+ noise freq in clouds.js).
|
|
166
|
+
this._cloud = [];
|
|
167
|
+
const CLUSTERS = 7, centers = [];
|
|
168
|
+
const scatter = (spread) => { const a = Math.random() * Math.PI * 2, rr = Math.pow(Math.random(), 1.1) * spread; return { x: Math.cos(a) * rr, z: Math.sin(a) * rr }; };
|
|
169
|
+
for (let k = 0; k < CLUSTERS; k++) centers.push(scatter(CLOUD_MAXR * 0.85));
|
|
170
|
+
for (let i = 0; i < N; i++) {
|
|
171
|
+
const solo = Math.random() < 0.26;
|
|
172
|
+
const base = solo ? scatter(CLOUD_MAXR) : centers[(Math.random() * CLUSTERS) | 0];
|
|
173
|
+
this._cloud.push({
|
|
174
|
+
x: base.x + (solo ? 0 : (Math.random() - 0.5) * 1.7), // horizontal position (sky units) — clump jitter
|
|
175
|
+
z: base.z + (solo ? 0 : (Math.random() - 0.5) * 1.7),
|
|
176
|
+
h: 0.9 + Math.random() * 0.5, // altitude → higher sits higher + smaller (depth)
|
|
177
|
+
spd: 0.75 + Math.random() * 0.5, // per-cloud drift speed (parallax: some sail faster)
|
|
178
|
+
s: 0.15 + Math.pow(Math.random(), 1.3) * 0.5, // angular size — bigger floor + less small-bias (Nick: too many tiny clouds)
|
|
179
|
+
wx: 0.85 + Math.random() * 0.55, // width — rounder
|
|
180
|
+
asp: 0.5 + Math.random() * 0.35, // flatness — body
|
|
181
|
+
roll: (Math.random() - 0.5) * 0.5, // small tilt only
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
scene.add(this.clouds);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// drift the dome-anchored clouds; tint by the current phase + WEATHER (uCloudiness leads the
|
|
189
|
+
// rain/snow: the deck swells + darkens BEFORE precipitation starts and thins after it stops)
|
|
190
|
+
_updateClouds(dt, center, p, dayness) {
|
|
191
|
+
const R = this.skyR * 0.88, m = _cloudM, q = _cloudQ, v = _cloudV;
|
|
192
|
+
const w = uCloudiness.value;
|
|
193
|
+
// DRIFT WITH THE GLOBAL WIND (wind.js writes uWindDir/uWindStr): translate the whole field along the
|
|
194
|
+
// current bearing so every cloud sails the SAME way the tumbleweeds/grass/rain lean — gusts push harder.
|
|
195
|
+
const wx = uWindDir.value.x, wz = uWindDir.value.y;
|
|
196
|
+
const drift = (0.008 + uWindStr.value * 0.014) * Math.min(dt, 0.1); // gentle — a light breeze crosses the sky in ~10+ min, not a hurricane
|
|
197
|
+
let shx = uSunDir.value.x, shz = uSunDir.value.z; const shl = Math.hypot(shx, shz) || 1; shx /= shl; shz /= shl; // horizontal sun bearing → which side of each cloud is lit
|
|
198
|
+
const sun = this._aSun.array;
|
|
199
|
+
for (let i = 0; i < this._cloud.length; i++) {
|
|
200
|
+
const c = this._cloud[i];
|
|
201
|
+
c.x += wx * drift * c.spd; c.z += wz * drift * c.spd;
|
|
202
|
+
if (c.x * c.x + c.z * c.z > CLOUD_MAXR * CLOUD_MAXR) { // sailed off the leeward edge → re-enter on the WINDWARD side
|
|
203
|
+
// Respawn across the WINDWARD half of the field (outer band = mostly low on the horizon), ALWAYS
|
|
204
|
+
// inside the radius. The old math put a wide perpendicular spread OUTSIDE the field, so those
|
|
205
|
+
// clouds re-triggered the recycle every frame and only the windward strip stayed fed — which
|
|
206
|
+
// left half the sky bare (Nick). Folding a disk sample onto the windward half keeps density even.
|
|
207
|
+
const a = Math.random() * 6.2832;
|
|
208
|
+
const rr = (0.45 + 0.55 * Math.random()) * CLOUD_MAXR * 0.95;
|
|
209
|
+
let nx = Math.cos(a) * rr, nz = Math.sin(a) * rr;
|
|
210
|
+
if (nx * wx + nz * wz > 0) { nx = -nx; nz = -nz; } // fold onto the windward half so it drifts back across the sky
|
|
211
|
+
c.x = nx; c.z = nz;
|
|
212
|
+
}
|
|
213
|
+
_cloudDir.set(c.x, c.h, c.z);
|
|
214
|
+
const L = _cloudDir.length() || 1;
|
|
215
|
+
_cloudDir.multiplyScalar(1 / L);
|
|
216
|
+
const sizeFac = Math.max(0.6, c.h / L); // perspective: far/low clouds shrink a bit (floor 0.6 so they don't go tiny)
|
|
217
|
+
// Billboard on the sphere, but keep the quad's UP aligned to WORLD UP so wide/flat clouds stay
|
|
218
|
+
// HORIZONTAL — like real cloud banks. (setFromUnitVectors left the up arbitrary, which tilted every
|
|
219
|
+
// wide cloud to a random angle: Nick's "slash-shaped clouds at weird angles".) Basis = (right, up,
|
|
220
|
+
// faceDir); a small per-cloud roll is the only tilt.
|
|
221
|
+
_cloudRight.crossVectors(_cloudUp, _cloudDir);
|
|
222
|
+
if (_cloudRight.lengthSq() < 1e-6) _cloudRight.set(1, 0, 0); // straight overhead: world-up ∥ view dir
|
|
223
|
+
_cloudRight.normalize();
|
|
224
|
+
sun[i] = shx * _cloudRight.x + shz * _cloudRight.z; // −1 sun to the cloud's left … +1 to its right
|
|
225
|
+
_cloudUp2.crossVectors(_cloudDir, _cloudRight).normalize();
|
|
226
|
+
_cloudB.makeBasis(_cloudRight, _cloudUp2, _cloudDir);
|
|
227
|
+
q.setFromRotationMatrix(_cloudB);
|
|
228
|
+
q.multiply(_cloudQ2.setFromAxisAngle(_cloudZ, c.roll)); // small tilt for variety only
|
|
229
|
+
const s = c.s * R * (1 + w * 0.7) * sizeFac; // storm deck swells clouds; sizeFac shrinks the far/low ones for depth
|
|
230
|
+
m.compose(v.copy(center).addScaledVector(_cloudDir, R), q, _cloudS.set(s * c.wx, s * c.asp, 1));
|
|
231
|
+
this.clouds.setMatrixAt(i, m);
|
|
232
|
+
}
|
|
233
|
+
this.clouds.instanceMatrix.needsUpdate = true;
|
|
234
|
+
this._aSun.needsUpdate = true;
|
|
235
|
+
// day: white puffs; dusk: catch the phase colour; night: dim moonlit grey; storm: slate-dark
|
|
236
|
+
this._cloudTint.value.copy(WHITE).lerp(p.sky, 0.25).lerp(STORM_CLOUD, w * 0.85).multiplyScalar(lerp(0.32, 1, dayness));
|
|
237
|
+
this._cloudOpacity.value = lerp(lerp(0.5, 0.72, dayness), 0.96, w);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
get isNight() { return this.hour < 4.8 || this.hour > 21.4; }
|
|
241
|
+
|
|
242
|
+
update(dt, center, moorAmount = 0) {
|
|
243
|
+
this.hour = (this.hour + dt * this.speed) % 24;
|
|
244
|
+
const h = this.hour;
|
|
245
|
+
const p = this._phase;
|
|
246
|
+
|
|
247
|
+
// phase blending: night -> dawn -> day -> dusk -> night
|
|
248
|
+
if (h < 4) blendPhase(h, NIGHT, NIGHT, p, 0);
|
|
249
|
+
else if (h < 5.5) blendPhase(h, NIGHT, DUSK, p, smoothstep(4, 5.5, h));
|
|
250
|
+
else if (h < 7.5) blendPhase(h, DUSK, DAY, p, smoothstep(5.5, 7.5, h));
|
|
251
|
+
else if (h < 19) blendPhase(h, DAY, DAY, p, 0);
|
|
252
|
+
else if (h < 20.8) blendPhase(h, DAY, DUSK, p, smoothstep(19, 20.8, h)); // golden hour while the sun drops
|
|
253
|
+
else if (h < 22.2) blendPhase(h, DUSK, NIGHT, p, smoothstep(20.8, 22.2, h)); // sun gone ~21:15 → dark by ~22
|
|
254
|
+
else blendPhase(h, NIGHT, NIGHT, p, 0);
|
|
255
|
+
|
|
256
|
+
// Gravemoor dread tint
|
|
257
|
+
if (moorAmount > 0) {
|
|
258
|
+
p.fog.lerp(MOOR_TINT, moorAmount * 0.7);
|
|
259
|
+
p.sky.lerp(MOOR_TINT, moorAmount * 0.45);
|
|
260
|
+
p.horizon.lerp(MOOR_TINT, moorAmount * 0.55);
|
|
261
|
+
p.sunI *= 1 - moorAmount * 0.35;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// OVERCAST: rain/storm greys the SKY, not just the clouds. Desaturate + darken the phase colours
|
|
265
|
+
// toward a flat slate and pull the sky down toward the horizon (an overcast sky has little top-to-
|
|
266
|
+
// bottom gradient). Weather-driven off uCloudiness (which LEADS the rain), and day/night-aware because
|
|
267
|
+
// it works from each colour's OWN luminance — a stormy night stays dark, not grey-bright. (Nick: the
|
|
268
|
+
// sky stayed light blue while it rained.)
|
|
269
|
+
const wsky = uCloudiness.value;
|
|
270
|
+
if (wsky > 0.01) {
|
|
271
|
+
const k = 0.9 * wsky; // blend strength toward the storm slate
|
|
272
|
+
for (const col of [p.sky, p.horizon, p.fog]) {
|
|
273
|
+
const l = col.r * 0.3 + col.g * 0.59 + col.b * 0.11; // this colour's OWN luminance (keeps it day/night-aware)
|
|
274
|
+
const t = l * 0.27 + 0.035; // a DARK neutral target ~0.27× the luminance
|
|
275
|
+
col.lerp(_grey.setRGB(t, t, t), k);
|
|
276
|
+
// then KILL the residual blue — the day sky's blue channel is so high a plain lerp leaves it tinted,
|
|
277
|
+
// and a storm sky is a neutral GREY, not blue. Pull each channel toward the colour's mean.
|
|
278
|
+
const m = (col.r + col.g + col.b) / 3;
|
|
279
|
+
col.r += (m - col.r) * 0.6 * wsky; col.g += (m - col.g) * 0.6 * wsky; col.b += (m - col.b) * 0.6 * wsky;
|
|
280
|
+
}
|
|
281
|
+
p.sky.lerp(p.horizon, 0.4 * wsky); // flatten the dome gradient
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// dome colors — recompute only when light meaningfully changes (~every
|
|
285
|
+
// few game-minutes), not per frame: this loop touches ~560 vertices.
|
|
286
|
+
const domeKey = `${h.toFixed(1)}:${moorAmount.toFixed(1)}:${(uCloudiness.value * 20) | 0}`;
|
|
287
|
+
if (domeKey !== this._domeKey) {
|
|
288
|
+
this._domeKey = domeKey;
|
|
289
|
+
const pos = this.domeGeo.attributes.position;
|
|
290
|
+
const col = this.domeGeo.attributes.color;
|
|
291
|
+
const c = new THREE.Color();
|
|
292
|
+
for (let i = 0; i < pos.count; i++) {
|
|
293
|
+
const t = clamp(pos.getY(i) / 340, -0.1, 1);
|
|
294
|
+
c.copy(p.horizon).lerp(p.sky, smoothstep(0, 0.55, t));
|
|
295
|
+
col.setXYZ(i, c.r, c.g, c.b);
|
|
296
|
+
}
|
|
297
|
+
col.needsUpdate = true;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// sun orbit: a SOUTHERN arc (alt-Britain sits at UK latitude — the sun never reaches the
|
|
301
|
+
// zenith; it crosses the southern sky at ~55° max). The old orbit put noon straight overhead,
|
|
302
|
+
// where no camera ever looks ("I see clouds but no sun") and the cloud deck sat in front of it.
|
|
303
|
+
// asymmetric day: above the horizon 05:00-21:00 (British summer), below overnight — Nick's
|
|
304
|
+
// spec: the sun shouldn't touch the horizon until ~20:30 and should be fully gone by ~21:30.
|
|
305
|
+
const SUNRISE = 5, SUNSET = 21, dayLen = SUNSET - SUNRISE;
|
|
306
|
+
const ang = (h >= SUNRISE && h < SUNSET)
|
|
307
|
+
? ((h - SUNRISE) / dayLen) * Math.PI
|
|
308
|
+
: Math.PI + (((h - SUNSET + 24) % 24) / (24 - dayLen)) * Math.PI;
|
|
309
|
+
const sunDir = _sunDir.set(Math.cos(ang) * 0.55, Math.sin(ang) * 0.8, Math.cos(ang) * 0.3 + 0.5).normalize();
|
|
310
|
+
const dayness = smoothstep(-0.12, 0.12, sunDir.y);
|
|
311
|
+
this.dayness = dayness; // 0 = night, 1 = day — read by the renderer for night exposure dimming
|
|
312
|
+
uDayness.value = dayness; // shared TSL uniform — unlit impostor materials dim toward moonlit blue at night
|
|
313
|
+
uSkyCol.value.copy(p.sky); uHorizonCol.value.copy(p.horizon); // water fresnel reflections track the sky phases
|
|
314
|
+
|
|
315
|
+
// after sundown the directional light swings to the MOON's direction (opposite orbit,
|
|
316
|
+
// above the horizon) — following the sun below ground lit surfaces from underneath,
|
|
317
|
+
// i.e. moonless pitch-black nights
|
|
318
|
+
// The moon runs its own LOW arc (~31° elevation) instead of mirroring the sun through
|
|
319
|
+
// the zenith — near midnight the mirrored direction is straight up, where no camera
|
|
320
|
+
// ever looks ("there is literally no moon in the sky"). Azimuth mirrors the sun's
|
|
321
|
+
// horizontal bearing, blending to a fixed SSE anchor when that bearing vanishes.
|
|
322
|
+
let mx = -sunDir.x, mz = -sunDir.z;
|
|
323
|
+
const ml = Math.hypot(mx, mz), mk = Math.min(1, ml / 0.3);
|
|
324
|
+
mx = (ml > 1e-4 ? mx / ml : 0) * mk + 0.35 * (1 - mk);
|
|
325
|
+
mz = (ml > 1e-4 ? mz / ml : 0) * mk + 0.85 * (1 - mk);
|
|
326
|
+
const mxz = Math.hypot(mx, mz) || 1;
|
|
327
|
+
const MOON_EL = 0.55; // radians above the horizon
|
|
328
|
+
const moonDir = _moonDir.set((mx / mxz) * Math.cos(MOON_EL), Math.sin(MOON_EL), (mz / mxz) * Math.cos(MOON_EL));
|
|
329
|
+
const lightDir = sunDir.y >= -0.05 ? sunDir : moonDir;
|
|
330
|
+
uSunDir.value.copy(lightDir); // shared TSL uniform — grass backlight follows the key light
|
|
331
|
+
this.sun.position.copy(center).addScaledVector(lightDir, 140);
|
|
332
|
+
this.sun.target.position.copy(center);
|
|
333
|
+
this.sun.color.copy(NIGHT.sun).lerp(p.sun, dayness); // EASE the key-light colour across the horizon crossing — the old hard `dayness>0.5` switch snapped the ground colour in one frame at ~sunset (Nick: "at 9:15 the ground colour changed in a blink")
|
|
334
|
+
this.sun.intensity = lerp(NIGHT.sunI, p.sunI, dayness);
|
|
335
|
+
|
|
336
|
+
this.hemi.intensity = p.ambI * 1.5; // desert lift: dark western wood crushed to black at Albion ambient levels
|
|
337
|
+
this.hemi.color.copy(p.sky).lerp(WHITE, 0.4);
|
|
338
|
+
|
|
339
|
+
const discR = this.skyR * 0.94; // inside the dome AND the far plane (LOW clips past 200m)
|
|
340
|
+
this.sunDisc.position.copy(center).addScaledVector(sunDir, discR);
|
|
341
|
+
this.sunDisc.visible = sunDir.y > -0.05;
|
|
342
|
+
this.sunHalo.position.copy(center).addScaledVector(sunDir, discR * 0.99);
|
|
343
|
+
this.sunHalo.visible = this.sunDisc.visible;
|
|
344
|
+
this.sunHalo.material.opacity = lerp(0.25, 0.85, dayness);
|
|
345
|
+
this.moonDisc.position.copy(center).addScaledVector(moonDir, discR);
|
|
346
|
+
this.moonDisc.visible = sunDir.y < 0.1;
|
|
347
|
+
this.moonDisc.material.color.copy(this.moonColor);
|
|
348
|
+
|
|
349
|
+
this.stars.material.opacity = 1 - dayness;
|
|
350
|
+
|
|
351
|
+
this.scene.fog.color.copy(p.fog);
|
|
352
|
+
if (this.scene.background?.copy) this.scene.background.copy(p.fog); // void past the world edge = sky/haze, not white
|
|
353
|
+
const fogScale = this.fogScale ?? 1;
|
|
354
|
+
this.scene.fog.near = lerp(26, 70, 1 - moorAmount * 0.7) * fogScale;
|
|
355
|
+
this.scene.fog.far = lerp(120, 320, (1 - moorAmount * 0.55) * lerp(0.6, 1, dayness)) * fogScale; // storm fog is pulled in by weather.js (uCloudiness) after this — no rain factor needed here
|
|
356
|
+
|
|
357
|
+
// keep dome/stars centred on the player
|
|
358
|
+
this.dome.position.copy(center);
|
|
359
|
+
this.stars.position.copy(center);
|
|
360
|
+
|
|
361
|
+
this._updateClouds(dt, center, p, dayness);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Phase-2 static-env tile streaming: RESIDENT scatter meshes. Every spec owns ONE resident
|
|
2
|
+
// InstancedMesh set for the life of the app (built empty on the loading screen, capacity = the
|
|
3
|
+
// max any 5×5 tile window can hold); tiles stream by WRITING slices of the instance buffers +
|
|
4
|
+
// counts — no meshes are ever created, destroyed or re-parented at tile boundaries. That churn
|
|
5
|
+
// was the proven 150-500ms travel hitch: r184 WebGPU pays ~3 pipeline-cache entries per new
|
|
6
|
+
// InstancedMesh OBJECT even with fully shared geometry/material/texture. Terrain stays one
|
|
7
|
+
// analytic plane (heightAt, untouched). NEVER touches skinned meshes (player/NPCs/horses are
|
|
8
|
+
// resident-for-life too). Fed deterministic {pl, ensure, write, free} specs from the zones
|
|
9
|
+
// manifest, so a tile rebuilds identically whenever it's revisited.
|
|
10
|
+
import { buildBVH, buildBVHAsync } from './collision.js';
|
|
11
|
+
|
|
12
|
+
const TILE = 180; // metres; must match the manifest bucketing in zones.js
|
|
13
|
+
const RADIUS = 2; // 5×5 active set → nearest unloaded tile edge = 2*TILE = 360m > 320m max fog
|
|
14
|
+
const keyOf = (tx, tz) => tx * 100003 + tz;
|
|
15
|
+
// same exclusions as the monolithic world BVH (world.js): fences/flowers/paving/grass don't collide.
|
|
16
|
+
const COLLIDE_EXCLUDE = /fence|flower|path|brick|ground|floor|grass/i;
|
|
17
|
+
|
|
18
|
+
export class TileField {
|
|
19
|
+
constructor(scene, specs, { colliders = [] } = {}) {
|
|
20
|
+
this._colliders = colliders; // the game's live collider array — spliced on tile free
|
|
21
|
+
this.scene = scene;
|
|
22
|
+
this.specs = specs;
|
|
23
|
+
// bucket every spec's placements by tile → tileKey: [{ spec, sub:[placements in this tile] }]
|
|
24
|
+
this.buckets = new Map();
|
|
25
|
+
for (const spec of specs) {
|
|
26
|
+
const byTile = new Map();
|
|
27
|
+
for (const p of spec.pl) {
|
|
28
|
+
const k = keyOf(Math.floor(p.x / TILE), Math.floor(p.z / TILE));
|
|
29
|
+
let arr = byTile.get(k); if (!arr) byTile.set(k, (arr = []));
|
|
30
|
+
arr.push(p);
|
|
31
|
+
}
|
|
32
|
+
for (const [k, sub] of byTile) {
|
|
33
|
+
let arr = this.buckets.get(k); if (!arr) this.buckets.set(k, (arr = []));
|
|
34
|
+
arr.push({ spec, sub });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
this.loaded = new Map(); // tileKey → { disposed, bvh, bvhBuilding, tileCols, specs, lodCol }
|
|
38
|
+
this.queue = []; // tileKeys awaiting build, nearest-first
|
|
39
|
+
this.bvhQueue = []; // built tiles awaiting their (heavy) collision BVH — one per frame
|
|
40
|
+
this._cur = null; // last player tile, for crossing detection
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Build every spec's RESIDENT mesh set (empty) on the loading screen. Capacity per spec = the
|
|
44
|
+
// max instances any 5×5 window of ITS tiles can hold (windows centred outside a spec's tile
|
|
45
|
+
// bbox are dominated by the per-axis-clamped centre, so scanning the bbox is exact). This is
|
|
46
|
+
// also the boot-time prefetch of every far-biome FBX/LOD-glb/impostor-JSON/texture — the role
|
|
47
|
+
// the old warmAllTypes played — and it instantiates every cached tier material, so the boot
|
|
48
|
+
// warm render (main.js warmRenderHidden) compiles every scatter pipeline before play.
|
|
49
|
+
// MUST run BEFORE buildInitial, and AFTER the world-BVH bake (getLODColliderGroups reads
|
|
50
|
+
// lodGroups entry counts — resident entries register empty, so the bake stays clean either way).
|
|
51
|
+
async buildResidents() {
|
|
52
|
+
const t0 = performance.now();
|
|
53
|
+
let totalCap = 0, built = 0;
|
|
54
|
+
await Promise.all(this.specs.map(async (spec) => {
|
|
55
|
+
const byTile = new Map();
|
|
56
|
+
let minx = Infinity, maxx = -Infinity, minz = Infinity, maxz = -Infinity;
|
|
57
|
+
for (const p of spec.pl) {
|
|
58
|
+
const tx = Math.floor(p.x / TILE), tz = Math.floor(p.z / TILE);
|
|
59
|
+
const k = tx + ',' + tz;
|
|
60
|
+
byTile.set(k, (byTile.get(k) ?? 0) + 1);
|
|
61
|
+
if (tx < minx) minx = tx; if (tx > maxx) maxx = tx;
|
|
62
|
+
if (tz < minz) minz = tz; if (tz > maxz) maxz = tz;
|
|
63
|
+
}
|
|
64
|
+
let cap = 0;
|
|
65
|
+
for (let cx = minx; cx <= maxx; cx++) for (let cz = minz; cz <= maxz; cz++) {
|
|
66
|
+
let s = 0;
|
|
67
|
+
for (let i = -RADIUS; i <= RADIUS; i++) for (let j = -RADIUS; j <= RADIUS; j++) s += byTile.get((cx + i) + ',' + (cz + j)) ?? 0;
|
|
68
|
+
if (s > cap) cap = s;
|
|
69
|
+
}
|
|
70
|
+
const h = await spec.ensure(cap);
|
|
71
|
+
if (h?.group) { this.scene.add(h.group); built++; totalCap += cap; }
|
|
72
|
+
}));
|
|
73
|
+
console.log(`[resident] ${built}/${this.specs.length} scatter specs resident in ${Math.round(performance.now() - t0)}ms · Σ window capacity ${totalCap} instances`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
update(px, pz) {
|
|
77
|
+
const tx = Math.floor(px / TILE), tz = Math.floor(pz / TILE);
|
|
78
|
+
if (!this._cur || this._cur.tx !== tx || this._cur.tz !== tz) {
|
|
79
|
+
this._cur = { tx, tz };
|
|
80
|
+
const desired = new Set();
|
|
81
|
+
for (let i = -RADIUS; i <= RADIUS; i++) for (let j = -RADIUS; j <= RADIUS; j++) desired.add(keyOf(tx + i, tz + j));
|
|
82
|
+
// dispose tiles that left the active set FIRST — frees their slices, so the incoming
|
|
83
|
+
// ring can never overflow the window-sized resident buffers
|
|
84
|
+
for (const k of [...this.loaded.keys()]) if (!desired.has(k)) this._dispose(k);
|
|
85
|
+
// (re)build the build queue, nearest ring first, only tiles that have content + aren't live
|
|
86
|
+
this.queue = [];
|
|
87
|
+
for (let r = 0; r <= RADIUS; r++) for (let i = -r; i <= r; i++) for (let j = -r; j <= r; j++) {
|
|
88
|
+
if (Math.max(Math.abs(i), Math.abs(j)) !== r) continue; // only the cells on ring r
|
|
89
|
+
const k = keyOf(tx + i, tz + j);
|
|
90
|
+
if (this.buckets.has(k) && !this.loaded.has(k)) this.queue.push(k);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (this.queue.length) this._build(this.queue.shift()); // one tile per update — frame-spread
|
|
94
|
+
// deferred collision: dispatch finished tiles' BVH builds to the WORKER (off-thread). The build
|
|
95
|
+
// itself (~30-80ms, the old town streaming hitch) no longer runs on the main thread — all we do
|
|
96
|
+
// here is gather matrices + post. Throttle to a couple of dispatches/frame to keep that gather cheap.
|
|
97
|
+
let dispatched = 0;
|
|
98
|
+
while (this.bvhQueue.length && dispatched < 2) {
|
|
99
|
+
const live = this.bvhQueue.shift();
|
|
100
|
+
if (live.disposed || live.bvh || live.bvhBuilding) continue;
|
|
101
|
+
live.bvhBuilding = true;
|
|
102
|
+
this._buildCollidersAsync(live);
|
|
103
|
+
dispatched++;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Build the spawn neighbourhood up front (awaited), during the loading screen, so the player never
|
|
108
|
+
// drops into an empty world. Same 5×5 set as update() but built all at once, not frame-spread.
|
|
109
|
+
// Disposes any tiles outside the target window first, so a teleport/save-load re-seat can call it
|
|
110
|
+
// without overflowing the resident slice tables.
|
|
111
|
+
async buildInitial(px, pz) {
|
|
112
|
+
const tx = Math.floor(px / TILE), tz = Math.floor(pz / TILE);
|
|
113
|
+
this._cur = { tx, tz };
|
|
114
|
+
const desired = new Set();
|
|
115
|
+
for (let i = -RADIUS; i <= RADIUS; i++) for (let j = -RADIUS; j <= RADIUS; j++) desired.add(keyOf(tx + i, tz + j));
|
|
116
|
+
for (const k of [...this.loaded.keys()]) if (!desired.has(k)) this._dispose(k);
|
|
117
|
+
const builds = [];
|
|
118
|
+
for (const k of desired) {
|
|
119
|
+
if (this.buckets.has(k) && !this.loaded.has(k)) builds.push(this._build(k));
|
|
120
|
+
}
|
|
121
|
+
await Promise.all(builds);
|
|
122
|
+
// build the spawn ring's colliders up front (we're on the loading screen — no frame budget needed)
|
|
123
|
+
for (const live of this.bvhQueue) if (!live.disposed) this._buildColliders(live);
|
|
124
|
+
this.bvhQueue.length = 0;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Stream a tile IN: write each spec's placement slice into its resident buffers. Pure typed-array
|
|
128
|
+
// writes — no meshes, no scene-graph changes, no frame yields needed (a whole tile is well under
|
|
129
|
+
// a millisecond once residents exist; the awaits only suspend during boot's asset loads).
|
|
130
|
+
async _build(k) {
|
|
131
|
+
if (this.loaded.has(k) || !this.buckets.has(k)) return;
|
|
132
|
+
const live = { disposed: false, bvh: null, bvhBuilding: false, tileCols: [], specs: [], lodCol: [] };
|
|
133
|
+
this.loaded.set(k, live); // claim immediately so a re-enqueue can't double-build
|
|
134
|
+
for (const { spec, sub } of this.buckets.get(k)) {
|
|
135
|
+
let bvhMats = null;
|
|
136
|
+
try { bvhMats = await spec.write(k, sub, (c) => live.tileCols.push(c)); } catch (e) { bvhMats = null; console.warn(`[tiles] spec '${spec.name ?? '?'}' write failed for tile ${k} — props + collision missing there`, e?.message ?? e); }
|
|
137
|
+
if (live.disposed) { spec.free(k); continue; } // tile left mid-build (boot-time await) → back out
|
|
138
|
+
live.specs.push(spec);
|
|
139
|
+
const h = spec.handle?.();
|
|
140
|
+
if (bvhMats && h?.colliderGeometry && !COLLIDE_EXCLUDE.test(h.colliderName || '')) {
|
|
141
|
+
// LOD chains + plain scatter collide via LOD0 geometry × the FULL tile instance set
|
|
142
|
+
// (never the live LOD-bucketed count); the per-tile matrix copy survives slice compaction.
|
|
143
|
+
live.lodCol.push({ name: h.colliderName, geometry: h.colliderGeometry, matrices: bvhMats, count: sub.length });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (!live.disposed) this.bvhQueue.push(live); // defer BVH → update() dispatches to the worker
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Stream a tile OUT: free each spec's slice (compaction inside the resident entry). No scene
|
|
150
|
+
// or mesh changes — the render list is untouched by boundary crossings.
|
|
151
|
+
_dispose(k) {
|
|
152
|
+
const live = this.loaded.get(k);
|
|
153
|
+
if (!live) return;
|
|
154
|
+
this.loaded.delete(k);
|
|
155
|
+
live.disposed = true; // any in-flight build for this tile will bail + back out
|
|
156
|
+
for (const spec of live.specs) { try { spec.free(k); } catch { /* spec never resolved */ } }
|
|
157
|
+
live.specs.length = 0;
|
|
158
|
+
live.lodCol.length = 0;
|
|
159
|
+
live.bvh?.geometry?.dispose?.(); live.bvh = null; // three-mesh-bvh doesn't auto-dispose its geometry
|
|
160
|
+
if (live.tileCols?.length) { // drop this tile's 2D trunk circles from the global list (else re-entry duplicates them forever)
|
|
161
|
+
const rm = new Set(live.tileCols);
|
|
162
|
+
const cs = this._colliders;
|
|
163
|
+
for (let i = cs.length - 1; i >= 0; i--) if (rm.has(cs[i])) cs.splice(i, 1);
|
|
164
|
+
live.tileCols.length = 0;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Synchronous BVH — only for the spawn ring at boot (loading screen, no frame budget to protect).
|
|
169
|
+
_buildColliders(live) {
|
|
170
|
+
if (live.lodCol.length) { try { live.bvh = buildBVH([], live.lodCol); } catch { live.bvh = null; } }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Off-main-thread BVH (bvhWorker.js) — for runtime streamed tiles, so the build never blocks a frame.
|
|
174
|
+
// The tile collides as soon as the worker returns (a frame or two later); until then forEachBVHNear
|
|
175
|
+
// simply skips it (bvh null), which is imperceptible.
|
|
176
|
+
_buildCollidersAsync(live) {
|
|
177
|
+
if (!live.lodCol.length) { live.bvhBuilding = false; return; }
|
|
178
|
+
buildBVHAsync([], live.lodCol).then((bvh) => {
|
|
179
|
+
live.bvhBuilding = false;
|
|
180
|
+
if (live.disposed) { bvh?.geometry?.dispose?.(); return; } // tile left while building → toss it
|
|
181
|
+
if (bvh) live.bvh = bvh;
|
|
182
|
+
}).catch(() => { live.bvhBuilding = false; });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Run cb(bvh) for every loaded tile near (x,z) — the tile containing it + its 8 neighbours, so a
|
|
186
|
+
// capsule straddling a boundary still sees both sides' colliders. world._capsuleStep uses this.
|
|
187
|
+
forEachBVHNear(x, z, cb) {
|
|
188
|
+
const tx = Math.floor(x / TILE), tz = Math.floor(z / TILE);
|
|
189
|
+
for (let i = -1; i <= 1; i++) for (let j = -1; j <= 1; j++) {
|
|
190
|
+
const live = this.loaded.get(keyOf(tx + i, tz + j));
|
|
191
|
+
if (live?.bvh) cb(live.bvh);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// diagnostics
|
|
196
|
+
info() { return { loaded: this.loaded.size, tiles: this.buckets.size, queued: this.queue.length, specs: this.specs.length }; }
|
|
197
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// ── WALK-OVER GROUND DRESSING — the analytic lift under every dust pile ─────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// The dust piles (SM_Env_DustPile_*) are resFlat ground dressing: their colliders are named
|
|
4
|
+
// `scatterGround:` and the tile BVH bake drops them by NAME (tileManager COLLIDE_EXCLUDE), which
|
|
5
|
+
// is RIGHT — a BVH full of pebbles stops horses — but it left every walker clipping THROUGH the
|
|
6
|
+
// one piece of dressing tall enough to read as ground. Nick, verbatim: "yeah they need to walk
|
|
7
|
+
// over them too".
|
|
8
|
+
//
|
|
9
|
+
// So the piles are terrain now, analytically: world.groundAt returns heightAt + humpLift, and
|
|
10
|
+
// humpLift is a rotated elliptical dome per pile, derived from the SAME placement rows the
|
|
11
|
+
// renderer draws (zones.js registers them at boot, identically on the cooked and computed paths —
|
|
12
|
+
// no cook data moves). ONE seam lifts everything that walks: the player capsule's terrain floor,
|
|
13
|
+
// the NPCs and children, the ambient horses, the travellers and the herds all read world.groundAt.
|
|
14
|
+
// And a lift can never BLOCK movement — it is a floor, not a wall.
|
|
15
|
+
//
|
|
16
|
+
// The coach and the train read ground too, but the placement law keeps piles ≥6 m (town) / ≥8 m
|
|
17
|
+
// (desert) off every road centreline and their lanes run at ±1.15 / ±3.6 m — no pile can reach a
|
|
18
|
+
// wheel-line. No special case needed.
|
|
19
|
+
//
|
|
20
|
+
// PERF LAW: fixed 16 m grid over the placements, numeric cell keys, flat number rows — ZERO
|
|
21
|
+
// allocation per query, and the common case (no pile within reach) exits on an empty cell.
|
|
22
|
+
|
|
23
|
+
// Footprint half-extents (x, z) and bbox apex, measured off the GLBs (probe: gltf-transform bbox).
|
|
24
|
+
// The apex rides above the −0.08 m seat placementField sinks every pile by.
|
|
25
|
+
const DIMS = {
|
|
26
|
+
SM_Env_DustPile_Large_01: [5.66, 5.66, 0.82],
|
|
27
|
+
SM_Env_DustPile_Small_02: [1.35, 1.41, 0.82],
|
|
28
|
+
SM_Env_DustPile_Small_01: [1.29, 1.42, 0.54],
|
|
29
|
+
SM_Env_DustPile_Small_04: [0.37, 0.45, 0.22],
|
|
30
|
+
SM_Env_DustPile_Long_02: [2.98, 1.0, 0.62],
|
|
31
|
+
};
|
|
32
|
+
const SEAT = 0.08; // placementField seats piles at heightAt − 0.08
|
|
33
|
+
const CELL = 16, HALF = 512;
|
|
34
|
+
const key = (cx, cz) => (cx + HALF) * 4096 + (cz + HALF);
|
|
35
|
+
const _grid = new Map(); // int key → flat rows [x, z, cos, sin, 1/rx, 1/rz, apex] × n
|
|
36
|
+
|
|
37
|
+
export function registerHumps(file, placements) {
|
|
38
|
+
const dims = DIMS[file];
|
|
39
|
+
if (!dims) throw new Error(`[walkHumps] no dome dims for ${file} — measure the GLB and add it to DIMS`);
|
|
40
|
+
for (const p of placements) {
|
|
41
|
+
const rx = dims[0] * p.s, rz = dims[1] * p.s, h = dims[2] * p.s - SEAT;
|
|
42
|
+
if (h <= 0.05) continue; // a 5 cm mound is texture, not terrain
|
|
43
|
+
const c = Math.cos(p.ry), s = Math.sin(p.ry);
|
|
44
|
+
const r = Math.max(rx, rz);
|
|
45
|
+
for (let cx = Math.floor((p.x - r) / CELL); cx <= Math.floor((p.x + r) / CELL); cx++)
|
|
46
|
+
for (let cz = Math.floor((p.z - r) / CELL); cz <= Math.floor((p.z + r) / CELL); cz++) {
|
|
47
|
+
const k = key(cx, cz);
|
|
48
|
+
let cell = _grid.get(k);
|
|
49
|
+
if (!cell) _grid.set(k, cell = []);
|
|
50
|
+
cell.push(p.x, p.z, c, s, 1 / rx, 1 / rz, h);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The dome: apex·(1−d²)^1.5 in the pile's rotated elliptical frame — a fair read of the Synty
|
|
56
|
+
// mound's profile — and the MAX over overlapping piles, never the sum (two piles half-merged are
|
|
57
|
+
// one lump of dirt, not a tower).
|
|
58
|
+
export function humpLift(x, z) {
|
|
59
|
+
const cell = _grid.get(key(Math.floor(x / CELL), Math.floor(z / CELL)));
|
|
60
|
+
if (!cell) return 0;
|
|
61
|
+
let lift = 0;
|
|
62
|
+
for (let i = 0; i < cell.length; i += 7) {
|
|
63
|
+
const dx = x - cell[i], dz = z - cell[i + 1];
|
|
64
|
+
const c = cell[i + 2], s = cell[i + 3];
|
|
65
|
+
const u = (dx * c - dz * s) * cell[i + 4]; // world → pile frame (undo rotation.y = ry)
|
|
66
|
+
const v = (dx * s + dz * c) * cell[i + 5];
|
|
67
|
+
const d2 = u * u + v * v;
|
|
68
|
+
if (d2 >= 1) continue;
|
|
69
|
+
const k = 1 - d2;
|
|
70
|
+
const l = cell[i + 6] * k * Math.sqrt(k);
|
|
71
|
+
if (l > lift) lift = l;
|
|
72
|
+
}
|
|
73
|
+
return lift;
|
|
74
|
+
}
|