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
|
@@ -0,0 +1,1077 @@
|
|
|
1
|
+
// Instanced scatter: merges a Synty FBX into one geometry and stamps it
|
|
2
|
+
// across the world with InstancedMesh — forests cost a few draw calls.
|
|
3
|
+
import * as THREE from 'three/webgpu';
|
|
4
|
+
import {
|
|
5
|
+
uniform, float, positionWorld, cameraPosition, smoothstep, oneMinus,
|
|
6
|
+
Fn, attribute, vec2, vec3, vec4, positionGeometry, uv, modelViewMatrix,
|
|
7
|
+
cameraProjectionMatrix, normalize, abs, floor, texture, max,
|
|
8
|
+
sin, hash, instanceIndex, time,
|
|
9
|
+
} from 'three/tsl';
|
|
10
|
+
import * as BufferGeometryUtils from 'three/addons/utils/BufferGeometryUtils.js';
|
|
11
|
+
import { loadModel, loadTexture, loadGLB, resolveModelUrl } from '../core/assets.js';
|
|
12
|
+
import { uDayness, uWindDir, uWindStr } from './weatherUniforms.js';
|
|
13
|
+
import { waterNoiseTexture } from './waterNoise.js';
|
|
14
|
+
import { getQualityName, getQuality } from '../core/quality.js';
|
|
15
|
+
// Per-game LOD tables — register before any LOD chain is built:
|
|
16
|
+
// configureScatterLod(lodConfigJson, defaultTiers)
|
|
17
|
+
let LOD_CONFIG = {};
|
|
18
|
+
let DEFAULT_TIERS = null;
|
|
19
|
+
export function configureScatterLod(config, tiers) { LOD_CONFIG = config ?? {}; DEFAULT_TIERS = tiers ?? null; }
|
|
20
|
+
|
|
21
|
+
// Dithered LOD cross-fade runs on Medium and High; only Low hard-swaps.
|
|
22
|
+
// (Node materials + alphaHash cost more, and the dither can read as stippling
|
|
23
|
+
// on the WebGL2 fallback on older GPUs — Low skips it entirely, and the
|
|
24
|
+
// auto-tuner still steps down to Low if Medium can't hold framerate.)
|
|
25
|
+
const FADE = () => getQualityName() !== 'low';
|
|
26
|
+
|
|
27
|
+
// ---- distance LOD registry: scatterLOD() pairs a detailed near mesh with
|
|
28
|
+
// a glodify-generated far mesh; updateScatterLODs() rebuckets instances
|
|
29
|
+
// around the player every few hundred ms.
|
|
30
|
+
const lodGroups = [];
|
|
31
|
+
|
|
32
|
+
// LOD-dirty handshake for the caller's stationary skip (world.update): new chains/trees start
|
|
33
|
+
// UNBUCKETED (a fresh chain shows impostors-everywhere until its first rebucket), so a stream-in
|
|
34
|
+
// must force the next few updater calls even if the player is standing still. 3 runs = one full
|
|
35
|
+
// cycle of the amortized third-per-call round-robin updaters.
|
|
36
|
+
let _lodDirtyRuns = 3;
|
|
37
|
+
export function markLodDirty() { _lodDirtyRuns = 3; }
|
|
38
|
+
export function consumeLodRun() { if (_lodDirtyRuns > 0) { _lodDirtyRuns--; return true; } return false; }
|
|
39
|
+
|
|
40
|
+
// Conservative whole-group bounding sphere over every placement (+margin for the model's own size).
|
|
41
|
+
// Assigned to each LOD tier mesh ONCE at build so the per-rebucket computeBoundingSphere (a full
|
|
42
|
+
// pass over the repacked instance matrices, every tier, every rebucket) can be dropped — the
|
|
43
|
+
// sphere always covers every instance the tier could hold, so frustum culling stays correct.
|
|
44
|
+
function groupSphere(placements, margin = 14) {
|
|
45
|
+
let minX = 1e9, maxX = -1e9, minY = 1e9, maxY = -1e9, minZ = 1e9, maxZ = -1e9;
|
|
46
|
+
for (const p of placements) {
|
|
47
|
+
const y = p.y ?? 0;
|
|
48
|
+
if (p.x < minX) minX = p.x; if (p.x > maxX) maxX = p.x;
|
|
49
|
+
if (y < minY) minY = y; if (y > maxY) maxY = y;
|
|
50
|
+
if (p.z < minZ) minZ = p.z; if (p.z > maxZ) maxZ = p.z;
|
|
51
|
+
}
|
|
52
|
+
const c = new THREE.Vector3((minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2);
|
|
53
|
+
const r = Math.hypot(maxX - minX, maxY - minY, maxZ - minZ) / 2 + margin;
|
|
54
|
+
return new THREE.Sphere(c, r);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Flattened-geometry cache. flattenToGeometry merges an FBX/GLB into one BufferGeometry; the result
|
|
58
|
+
// is identical for a given (url, lod) regardless of placements. So cache it — a streamed tile then
|
|
59
|
+
// builds just an InstancedMesh over the SHARED geometry (cheap; no per-tile re-merge), which also
|
|
60
|
+
// cuts VRAM (one geometry, not one per tile). Cached geometries are tagged so disposeScatterGroup
|
|
61
|
+
// never frees them (they're shared across every tile that uses that model).
|
|
62
|
+
const geometryCache = new Map();
|
|
63
|
+
function flattenCached(url, group, lod = 0) {
|
|
64
|
+
const key = `${url}#${lod}`;
|
|
65
|
+
let flat = geometryCache.get(key);
|
|
66
|
+
if (flat === undefined) {
|
|
67
|
+
flat = flattenToGeometry(group, { lod });
|
|
68
|
+
if (flat?.geometry) flat.geometry.userData.cached = true;
|
|
69
|
+
geometryCache.set(key, flat ?? null);
|
|
70
|
+
}
|
|
71
|
+
return flat ?? null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Materials are identical per scatter TYPE (same texture + params) across every streamed tile, but
|
|
75
|
+
// were rebuilt on each tile load — a real per-tile hitch (measured: ~150-240ms/boundary even with
|
|
76
|
+
// geometry cached, largely the TSL tint node-graph construction). Cache + SHARE them across tiles,
|
|
77
|
+
// marked userData.cached so disposeScatterGroup keeps them resident (it now skips cached materials).
|
|
78
|
+
// Keyed by texture uuid + the params that change the material, so only truly-identical mats share.
|
|
79
|
+
const _matCache = new Map();
|
|
80
|
+
function cachedMat(key, make) {
|
|
81
|
+
let m = _matCache.get(key);
|
|
82
|
+
if (!m) { m = make(); m.userData.cached = true; _matCache.set(key, m); }
|
|
83
|
+
return m;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// LOD0 collision data for distance-LOD scatters: the NEAR (full-detail) geometry
|
|
87
|
+
// plus EVERY instance matrix, independent of the live near/far bucketing.
|
|
88
|
+
// Collision only matters where LOD0 renders (close to the player); anything on a
|
|
89
|
+
// far LOD is out of reach, so the far meshes are never baked into the collider.
|
|
90
|
+
export function getLODColliderGroups() {
|
|
91
|
+
return lodGroups.filter((g) => g.tiers?.[0]?.mesh).map((g) => ({
|
|
92
|
+
name: g.tiers[0].mesh.name,
|
|
93
|
+
geometry: g.tiers[0].mesh.geometry,
|
|
94
|
+
matrices: g.matrices,
|
|
95
|
+
count: g.count,
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Tile unload: free a scatterChain Group — drop its lodGroups entry (so updateScatterLODs stops
|
|
100
|
+
// touching disposed instances) and dispose every tier's geometry + material. Also works on a plain
|
|
101
|
+
// InstancedMesh (no scatterEntry → just disposes). Textures are loadTexture-cached/shared and are
|
|
102
|
+
// NOT disposed (Material.dispose doesn't touch them). No skinned meshes ever pass through here.
|
|
103
|
+
export function disposeScatterGroup(obj) {
|
|
104
|
+
const entry = obj.userData?.scatterEntry;
|
|
105
|
+
if (entry) { const i = lodGroups.indexOf(entry); if (i >= 0) lodGroups.splice(i, 1); }
|
|
106
|
+
const tEntry = obj.userData?.treeLodEntry;
|
|
107
|
+
if (tEntry) { const i = treeLodGroups.indexOf(tEntry); if (i >= 0) treeLodGroups.splice(i, 1); }
|
|
108
|
+
obj.traverse((o) => {
|
|
109
|
+
if (o.isInstancedMesh) o.dispose?.(); // frees the instance buffers (instanceMatrix)
|
|
110
|
+
if (!o.geometry?.userData?.cached) o.geometry?.dispose?.(); // shared cached geometry stays resident
|
|
111
|
+
const mats = Array.isArray(o.material) ? o.material : (o.material ? [o.material] : []);
|
|
112
|
+
for (const m of mats) if (!m.userData?.cached) m.dispose?.(); // shared cached material stays resident (reused across tiles)
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Shared transition band (metres) for the dithered LOD swap. The detailed NEAR
|
|
117
|
+
// mesh DISSOLVES OUT across [lo, hi]: the shader reads per-fragment camera
|
|
118
|
+
// distance and alphaHash turns its fractional opacity into a stable screen-door
|
|
119
|
+
// dither (order-independent — no transparency sorting). The FAR LOD stays SOLID
|
|
120
|
+
// underneath, so the dither holes reveal the LOD, never the sky. (Dithering
|
|
121
|
+
// both meshes with a shared hash punches holes clean through both → speckle.)
|
|
122
|
+
// updateScatterLODs() drives these each tick; lo===hi (Low quality) = hard swap.
|
|
123
|
+
const bandLo = uniform(54);
|
|
124
|
+
const bandHi = uniform(54.01);
|
|
125
|
+
const camDist = positionWorld.sub(cameraPosition).length();
|
|
126
|
+
const fadeOut = oneMinus(smoothstep(bandLo, bandHi, camDist)); // 1 near → 0 far
|
|
127
|
+
|
|
128
|
+
// Per-tree tonal variation (braffolk's per-instance hue/value jitter, re-keyed to
|
|
129
|
+
// WORLD SPACE so it survives our LOD rebucketing with no per-instance attribute):
|
|
130
|
+
// two smooth noise scales — broad stand-to-stand tone drift + tree-to-tree change.
|
|
131
|
+
// A gradient across one 6 m crown is imperceptible at this amplitude; between
|
|
132
|
+
// neighbouring trees it reads as individuals instead of copy-paste.
|
|
133
|
+
function tintAt(xz) {
|
|
134
|
+
const t = texture(waterNoiseTexture(), xz.mul(1 / 59)).r.mul(0.55)
|
|
135
|
+
.add(texture(waterNoiseTexture(), xz.mul(1 / 13.7)).g.mul(0.45));
|
|
136
|
+
return vec3(t.mul(0.22).add(0.90), float(1.04).sub(t.mul(0.10)), float(1.06).sub(t.mul(0.12)));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Material for one LOD side. `fading: 'near'` dithers out the detailed mesh;
|
|
140
|
+
// `fading: 'far'` is left fully solid (it's what the holes reveal).
|
|
141
|
+
function fadeMaterial({ map = null, vertexColors = false, fading }) {
|
|
142
|
+
const mat = new THREE.MeshStandardNodeMaterial({
|
|
143
|
+
roughness: vertexColors ? 1 : 0.9,
|
|
144
|
+
metalness: 0,
|
|
145
|
+
});
|
|
146
|
+
if (map) mat.map = map;
|
|
147
|
+
if (vertexColors) mat.vertexColors = true;
|
|
148
|
+
if (fading === 'near') {
|
|
149
|
+
mat.opacityNode = fadeOut; // dissolve the detailed mesh away with distance
|
|
150
|
+
mat.alphaHash = true; // stochastic dither — stable per world position
|
|
151
|
+
}
|
|
152
|
+
return mat;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---- per-object (non-instanced) dissolve, used for buildings ----
|
|
156
|
+
// Same dither, but the band edges are baked constants (each building has its
|
|
157
|
+
// own switch distance), so no shared uniform/bucketing is needed — the shader
|
|
158
|
+
// dissolves by live camera distance and updateBuildingLODs() just culls the
|
|
159
|
+
// fully-faded mesh so we don't draw both everywhere.
|
|
160
|
+
const buildingLODs = []; // { near, far, x, z, lo, hi }
|
|
161
|
+
|
|
162
|
+
// Clone a mesh's material into a dithered dissolve node material, preserving look.
|
|
163
|
+
function toFadeMaterial(src, ramp) {
|
|
164
|
+
const s = Array.isArray(src) ? src[0] : src;
|
|
165
|
+
const mat = new THREE.MeshStandardNodeMaterial({
|
|
166
|
+
roughness: s?.roughness ?? 0.9,
|
|
167
|
+
metalness: s?.metalness ?? 0,
|
|
168
|
+
});
|
|
169
|
+
if (s?.map) mat.map = s.map;
|
|
170
|
+
if (s?.vertexColors) mat.vertexColors = true;
|
|
171
|
+
if (s?.color) mat.color.copy(s.color);
|
|
172
|
+
mat.opacityNode = ramp;
|
|
173
|
+
mat.alphaHash = true;
|
|
174
|
+
return mat;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Dissolve the detailed meshes under `root` OUT across [near, far] (metres) with
|
|
178
|
+
// a stochastic dither. Only the near object is dithered — its LOD stays solid.
|
|
179
|
+
function applyObjectFade(root, { near, far }) {
|
|
180
|
+
const ramp = oneMinus(smoothstep(float(near), float(far), camDist));
|
|
181
|
+
root.traverse((o) => {
|
|
182
|
+
if (o.isMesh) o.material = toFadeMaterial(o.material, ramp);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Register a building's near (full) → far (LOD3 mesh) → optional impostor chain.
|
|
187
|
+
// near dissolves out at switchDist; if an impostor is given, the LOD3 stays solid
|
|
188
|
+
// until d2 then dissolves out over the (solid) impostor behind it. All objects must
|
|
189
|
+
// already be in the scene at the same transform.
|
|
190
|
+
export function registerBuildingLOD(near, far, x, z, switchDist, band, impostor = null, d2 = 0) {
|
|
191
|
+
const lo = band > 0 ? switchDist - band : switchDist;
|
|
192
|
+
const hi = band > 0 ? switchDist + band : switchDist;
|
|
193
|
+
if (band > 0) applyObjectFade(near, { near: lo, far: hi });
|
|
194
|
+
const entry = { near, far, impostor, x, z, lo, hi };
|
|
195
|
+
if (impostor) {
|
|
196
|
+
entry.d2lo = band > 0 ? d2 - band : d2;
|
|
197
|
+
entry.d2hi = band > 0 ? d2 + band : d2;
|
|
198
|
+
if (band > 0) applyObjectFade(far, { near: entry.d2lo, far: entry.d2hi }); // LOD3 dissolves into the impostor
|
|
199
|
+
}
|
|
200
|
+
buildingLODs.push(entry);
|
|
201
|
+
markLodDirty(); // phase-2 castle etc. register while the player may be standing still
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// A single-building octahedral impostor billboard (non-instanced): centre/size as
|
|
205
|
+
// uniforms. Always camera-facing, samples the baked atlas by view angle.
|
|
206
|
+
export async function buildingImpostor(impJsonUrl, x, y, z, scale = 1, flipV = false) {
|
|
207
|
+
const desc = await fetch(impJsonUrl).then((r) => r.json());
|
|
208
|
+
const dir = impJsonUrl.slice(0, impJsonUrl.lastIndexOf('/'));
|
|
209
|
+
const albedo = await loadTexture(`${dir}/${desc.maps.albedo}`);
|
|
210
|
+
const S = 0.01;
|
|
211
|
+
const sz = (desc.billboardSize ?? 200) * S * scale;
|
|
212
|
+
const cy = (desc.bounds?.center?.[1] ?? 0) * S * scale;
|
|
213
|
+
const mat = impostorMaterial(albedo, desc, flipV, uniform(new THREE.Vector3(x, y + cy, z)), uniform(new THREE.Vector2(sz, sz)));
|
|
214
|
+
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), mat);
|
|
215
|
+
mesh.frustumCulled = false; mesh.castShadow = false; mesh.receiveShadow = false;
|
|
216
|
+
mesh.name = `buildingImpostor:${impJsonUrl.split('/').pop()}`;
|
|
217
|
+
return mesh;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Cull whichever tier has fully faded; render adjacent tiers only inside their band.
|
|
221
|
+
export function updateBuildingLODs(px, pz) {
|
|
222
|
+
for (const b of buildingLODs) {
|
|
223
|
+
const d = Math.hypot(b.x - px, b.z - pz);
|
|
224
|
+
b.near.visible = d < b.hi; // near fades out by hi
|
|
225
|
+
if (b.impostor) {
|
|
226
|
+
b.far.visible = d > b.lo && d < b.d2hi; // LOD3 lives between the two switches
|
|
227
|
+
b.impostor.visible = d > b.d2lo; // impostor takes over in the deep distance
|
|
228
|
+
} else {
|
|
229
|
+
b.far.visible = d > b.lo;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Merge meshes in an FBX group (transforms applied) into one geometry.
|
|
235
|
+
// Synty files often stack LOD0/LOD1/LOD2 meshes at the same spot (LOD2 is
|
|
236
|
+
// a flat imposter card) — merge ONLY the highest-detail LOD of each.
|
|
237
|
+
// Returns { geometry, hasColor }. hasColor is true when the merged meshes
|
|
238
|
+
// carried a COLOR_0 attribute (glodify's baked far-LOD vertex colors), so the
|
|
239
|
+
// caller renders vertex colors instead of the atlas texture.
|
|
240
|
+
// `split` (RegExp or name predicate): meshes whose NAME matches are pulled OUT of the merge and
|
|
241
|
+
// returned separately as `parts: [{ name, geometry }]`, in the same file-local frame. That is how a
|
|
242
|
+
// door leaf escapes the building weld — town.js merges a whole building into one geometry
|
|
243
|
+
// (and one BVH), which is exactly why a baked-in door can never swing or open. A predicate,
|
|
244
|
+
// because the caller's rule is "every door EXCEPT the ones on a see-through shell".
|
|
245
|
+
export function flattenToGeometry(group, { lod = 0, split = null } = {}) {
|
|
246
|
+
group.updateMatrixWorld(true);
|
|
247
|
+
const meshes = [];
|
|
248
|
+
group.traverse((o) => { if (o.isMesh) meshes.push(o); });
|
|
249
|
+
if (!meshes.length) return null;
|
|
250
|
+
|
|
251
|
+
const lodOf = (m) => {
|
|
252
|
+
const match = m.name.match(/_LOD(\d+)/i);
|
|
253
|
+
return match ? +match[1] : 0;
|
|
254
|
+
};
|
|
255
|
+
// pick the requested LOD, clamped to what the file actually has
|
|
256
|
+
// (LOD3 is usually a flat imposter card — never auto-pick beyond LOD2)
|
|
257
|
+
const lods = [...new Set(meshes.map(lodOf))].sort((a, b) => a - b);
|
|
258
|
+
const usable = lods.filter((l) => l <= Math.max(lod, 0) && l <= 2);
|
|
259
|
+
const chosen = usable.length ? usable[usable.length - 1] : lods[0];
|
|
260
|
+
|
|
261
|
+
// A baked far-LOD carries per-vertex color; keep it only if EVERY merged
|
|
262
|
+
// mesh has one (mergeGeometries requires matching attribute sets).
|
|
263
|
+
const all = meshes.filter((o) => lodOf(o) === chosen);
|
|
264
|
+
const hit = !split ? null : (typeof split === 'function' ? split : (n) => split.test(n));
|
|
265
|
+
const chosenMeshes = hit ? all.filter((o) => !hit(o.name)) : all;
|
|
266
|
+
const splitMeshes = hit ? all.filter((o) => hit(o.name)) : [];
|
|
267
|
+
const keepColor = chosenMeshes.every((o) => o.geometry.attributes.color);
|
|
268
|
+
|
|
269
|
+
const geos = [];
|
|
270
|
+
const parts = [];
|
|
271
|
+
for (const o of [...chosenMeshes, ...splitMeshes]) {
|
|
272
|
+
const g = o.geometry.clone();
|
|
273
|
+
// De-quantize int-packed attributes (KHR_mesh_quantization on the glodify glbs)
|
|
274
|
+
// to float BEFORE baking the matrix: applyMatrix4 writes transformed floats back
|
|
275
|
+
// into the attribute array, and an int16 buffer truncates them to garbage —
|
|
276
|
+
// collapsing the mesh into a tiny "box". getComponent() respects normalization.
|
|
277
|
+
for (const key of ['position', 'normal']) {
|
|
278
|
+
const a = g.attributes[key];
|
|
279
|
+
if (a && !(a.array instanceof Float32Array)) {
|
|
280
|
+
const out = new Float32Array(a.count * a.itemSize);
|
|
281
|
+
for (let i = 0; i < a.count; i++) for (let c = 0; c < a.itemSize; c++) out[i * a.itemSize + c] = a.getComponent(i, c);
|
|
282
|
+
g.setAttribute(key, new THREE.BufferAttribute(out, a.itemSize));
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
g.applyMatrix4(o.matrixWorld);
|
|
286
|
+
const allow = keepColor ? ['position', 'normal', 'uv', 'color'] : ['position', 'normal', 'uv'];
|
|
287
|
+
for (const key of Object.keys(g.attributes)) {
|
|
288
|
+
if (!allow.includes(key)) g.deleteAttribute(key);
|
|
289
|
+
}
|
|
290
|
+
if (!g.attributes.uv) {
|
|
291
|
+
g.setAttribute('uv', new THREE.BufferAttribute(new Float32Array(g.attributes.position.count * 2), 2));
|
|
292
|
+
}
|
|
293
|
+
if (splitMeshes.includes(o)) parts.push({ name: o.name, geometry: g });
|
|
294
|
+
else geos.push(g);
|
|
295
|
+
}
|
|
296
|
+
if (!geos.length) return null;
|
|
297
|
+
const geometry = geos.length === 1 ? geos[0] : BufferGeometryUtils.mergeGeometries(geos, false);
|
|
298
|
+
return { geometry, hasColor: keepColor, parts };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// tint (vegetation): world-space tonal jitter — needs a node material. alphaTest
|
|
302
|
+
// relies on the sampled alpha, which colorNode's rgb path drops, so never both.
|
|
303
|
+
// Cached + shared across tiles (world-space nodes → identical everywhere); this was the per-tile hitch.
|
|
304
|
+
function scatterMat(tex, alphaTest, tint, doubleSide, sway = false) {
|
|
305
|
+
return cachedMat(`${tex.uuid}|${alphaTest}|${tint}|${doubleSide}|${sway}`, () => {
|
|
306
|
+
const nodeMat = sway || (tint && !alphaTest); // sway also needs a node material (positionNode)
|
|
307
|
+
const mm = nodeMat
|
|
308
|
+
? new THREE.MeshStandardNodeMaterial({ map: tex, roughness: 0.9, metalness: 0 })
|
|
309
|
+
: new THREE.MeshStandardMaterial({ map: tex, roughness: 0.9, metalness: 0 });
|
|
310
|
+
if (tint && !alphaTest) mm.colorNode = texture(tex, uv()).rgb.mul(tintAt(positionWorld.xz));
|
|
311
|
+
if (alphaTest) { mm.alphaTest = alphaTest; mm.transparent = false; }
|
|
312
|
+
if (doubleSide) mm.side = THREE.DoubleSide;
|
|
313
|
+
if (sway) {
|
|
314
|
+
// WIND SWAY (crops): roots planted, tips nod on the GLOBAL wind (uWindDir/uWindStr). Amplitude
|
|
315
|
+
// grows with height² above the base (positionGeometry.y) and with the gust; a per-plant wobble
|
|
316
|
+
// keeps them out of lockstep. The lean is uWindDir, but each plant's own Y-rotation spins it a
|
|
317
|
+
// little, so a field reads as a RUSTLE stirring in the breeze rather than a rigid uniform lean.
|
|
318
|
+
const yb = positionGeometry.y.max(0);
|
|
319
|
+
const ph = hash(instanceIndex).mul(6.2832);
|
|
320
|
+
const wobble = sin(time.mul(1.7).add(ph)).mul(0.4).add(0.75);
|
|
321
|
+
const amp = yb.mul(yb).mul(0.032).mul(uWindStr.mul(1.4).add(0.3)).mul(wobble);
|
|
322
|
+
mm.positionNode = positionGeometry.add(vec3(uWindDir.x.mul(amp), float(0), uWindDir.y.mul(amp)));
|
|
323
|
+
}
|
|
324
|
+
return mm;
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Create an InstancedMesh of `url` at the given placements.
|
|
329
|
+
// placements: [{x, y, z, ry, s}]
|
|
330
|
+
export async function scatter(url, textureUrl, placements, { castShadow = true, alphaTest = 0, doubleSide = false, lod = 0, texture: texOverride = null, tint = false, sway = false } = {}) {
|
|
331
|
+
const master = await loadModel(url, { texture: textureUrl });
|
|
332
|
+
const flat = flattenCached(url, master, lod);
|
|
333
|
+
if (!flat) throw new Error(`no geometry in ${url}`);
|
|
334
|
+
const geo = flat.geometry;
|
|
335
|
+
// `texture` opt lets the caller pass a pre-built texture (e.g. the composited palm
|
|
336
|
+
// bark+leaf albedo with its own repeat/wrap) instead of loading one from a URL.
|
|
337
|
+
// atlas flip must match the RESOLVED master format: the twin redirect hands scatter
|
|
338
|
+
// glTF-convention geometry, and a default-flip (FBX-convention) atlas mirrors every
|
|
339
|
+
// palette cell — the red wells / washed market tents bug.
|
|
340
|
+
const isGLBMaster = /\.glb$/i.test(await resolveModelUrl(url, { texture: textureUrl }));
|
|
341
|
+
const tex = texOverride ?? await loadTexture(textureUrl, isGLBMaster ? { flipY: false } : {});
|
|
342
|
+
const mat = scatterMat(tex, alphaTest, tint, doubleSide, sway);
|
|
343
|
+
|
|
344
|
+
const mesh = new THREE.InstancedMesh(geo, mat, placements.length);
|
|
345
|
+
composeInto(mesh.instanceMatrix.array, 0, placements);
|
|
346
|
+
mesh.castShadow = castShadow;
|
|
347
|
+
mesh.receiveShadow = true;
|
|
348
|
+
mesh.instanceMatrix.needsUpdate = true;
|
|
349
|
+
mesh.name = `scatter:${url.split('/').pop()}`;
|
|
350
|
+
// Origin-placed (every instance carries its own world transform) → the mesh matrix is identity
|
|
351
|
+
// forever. Freeze it so scene.updateMatrixWorld (run inside the render phase) stops recomposing it
|
|
352
|
+
// every frame. Safe for streamed tiles too — same identity transform.
|
|
353
|
+
mesh.matrixAutoUpdate = false; mesh.updateMatrix();
|
|
354
|
+
return mesh;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// RESIDENT plain scatter: one InstancedMesh for the LIFE of the app, capacity-sized and empty;
|
|
358
|
+
// tiles stream in/out as slice writes (see SliceTable). Same cached geometry/material as scatter()
|
|
359
|
+
// — identical cachedMat key, so eager settlement props share the very same pipelines. `allPl` is
|
|
360
|
+
// every placement the spec can ever hold (the conservative frustum sphere; instances beyond the
|
|
361
|
+
// live window simply never render because count bounds them).
|
|
362
|
+
export async function scatterResident(url, textureUrl, cap, allPl, { castShadow = true, alphaTest = 0, doubleSide = false, lod = 0, texture: texOverride = null, tint = false, sway = false } = {}) {
|
|
363
|
+
const master = await loadModel(url, { texture: textureUrl });
|
|
364
|
+
const flat = flattenCached(url, master, lod);
|
|
365
|
+
if (!flat) throw new Error(`no geometry in ${url}`);
|
|
366
|
+
// atlas flip must match the RESOLVED master format: the twin redirect hands scatter
|
|
367
|
+
// glTF-convention geometry, and a default-flip (FBX-convention) atlas mirrors every
|
|
368
|
+
// palette cell — the red wells / washed market tents bug.
|
|
369
|
+
const isGLBMaster = /\.glb$/i.test(await resolveModelUrl(url, { texture: textureUrl }));
|
|
370
|
+
const tex = texOverride ?? await loadTexture(textureUrl, isGLBMaster ? { flipY: false } : {});
|
|
371
|
+
const mesh = new THREE.InstancedMesh(flat.geometry, scatterMat(tex, alphaTest, tint, doubleSide, sway), cap);
|
|
372
|
+
mesh.count = 0; // instanceMatrix is born zero-filled → the boot warm's count=1 flash draws a scale-0 instance (nothing)
|
|
373
|
+
mesh.castShadow = castShadow;
|
|
374
|
+
mesh.receiveShadow = true;
|
|
375
|
+
mesh.name = `scatter:${url.split('/').pop()}`;
|
|
376
|
+
mesh.boundingSphere = groupSphere(allPl); // conservative over every possible placement — never recomputed
|
|
377
|
+
mesh.matrixAutoUpdate = false; mesh.updateMatrix(); // origin-placed, instances carry transforms
|
|
378
|
+
const slices = new SliceTable(mesh.name, cap, [{ a: mesh.instanceMatrix.array, s: 16 }]);
|
|
379
|
+
return {
|
|
380
|
+
group: mesh,
|
|
381
|
+
colliderName: mesh.name,
|
|
382
|
+
colliderGeometry: flat.geometry, // LOD0 — the tile BVH bakes this × the tile's matrix slice
|
|
383
|
+
write(key, pl) {
|
|
384
|
+
const off = slices.alloc(key, pl.length);
|
|
385
|
+
if (off < 0) return null;
|
|
386
|
+
composeInto(mesh.instanceMatrix.array, off, pl);
|
|
387
|
+
mesh.count = slices.count;
|
|
388
|
+
mesh.instanceMatrix.needsUpdate = true;
|
|
389
|
+
return mesh.instanceMatrix.array.slice(off * 16, (off + pl.length) * 16); // per-tile copy for the tile BVH (survives compaction)
|
|
390
|
+
},
|
|
391
|
+
free(key) {
|
|
392
|
+
if (!slices.free(key)) return;
|
|
393
|
+
mesh.count = slices.count;
|
|
394
|
+
mesh.instanceMatrix.needsUpdate = true;
|
|
395
|
+
},
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ── Synty Nature-Biomes trees ──────────────────────────────────────────────
|
|
400
|
+
// These use the custom "Foliage" shadergraph: ONE mesh, ONE UV0, but TWO
|
|
401
|
+
// textures — _Trunk_Texture (opaque bark atlas) for the trunk/branch faces and
|
|
402
|
+
// _Leaf_Texture (a .tga with alpha, converted to PNG) for the leaf-card faces.
|
|
403
|
+
// The two face sets sit in DIFFERENT UV regions of the same 0..1 space (trunk
|
|
404
|
+
// clusters in a corner spot u<~0.15, leaves in the sprite rectangles). We split
|
|
405
|
+
// the LOD0 geometry by UV and stamp TWO InstancedMeshes: opaque trunk + alphaTest
|
|
406
|
+
// cutout leaves. (Proven in /treetest.html.) Returns a Group for the tile system.
|
|
407
|
+
const _treeTexCache = {};
|
|
408
|
+
function treeTex(url, kind = 'trunk', isTwin = false) {
|
|
409
|
+
// kind: 'trunk' (opaque bark atlas — mipped + anisotropic), 'leaf' (sprite cutout —
|
|
410
|
+
// no mipmaps so alpha edges don't alias into rainbow bands), 'card' (the terminal
|
|
411
|
+
// LOD's pre-rendered billboard atlas — like leaf but flipY=TRUE: it's authored
|
|
412
|
+
// crown-up, and the whole-sprite card makes the flip visible, unlike the tiny
|
|
413
|
+
// bark/leaf spots the mesh faces sample). See [[nature-biome-trees]].
|
|
414
|
+
// isTwin: the tree geometry resolved to a GLB twin (glTF V convention) — flips invert.
|
|
415
|
+
const key = `${url}#${kind}#${isTwin ? 'g' : 'f'}`;
|
|
416
|
+
if (!_treeTexCache[key]) _treeTexCache[key] = new THREE.TextureLoader().loadAsync(url).then((t) => {
|
|
417
|
+
t.colorSpace = THREE.SRGBColorSpace; t.wrapS = t.wrapT = THREE.ClampToEdgeWrapping;
|
|
418
|
+
// flip follows the RESOLVED geometry format: these constants were tuned against FBX
|
|
419
|
+
// trees (2026-07-04); the twin redirect inverted V, rendering every twinned tree's
|
|
420
|
+
// distance BILLBOARD upside-down (audit: card-mesh V correlation flips sign in twins).
|
|
421
|
+
// XOR restores the tuned texels for both formats; tropical trees have no twins
|
|
422
|
+
// (protected) and keep the FBX behaviour.
|
|
423
|
+
t.flipY = (kind === 'card') !== isTwin;
|
|
424
|
+
if (kind === 'leaf' || kind === 'card') {
|
|
425
|
+
t.generateMipmaps = false; t.minFilter = THREE.LinearFilter; t.magFilter = THREE.LinearFilter;
|
|
426
|
+
} else { t.anisotropy = 4; }
|
|
427
|
+
t.needsUpdate = true; return t;
|
|
428
|
+
});
|
|
429
|
+
return _treeTexCache[key];
|
|
430
|
+
}
|
|
431
|
+
// GPU-upload every cached tree texture at boot (same tex:+1 first-sight hitch as assets.warmTextures).
|
|
432
|
+
export async function warmTreeTextures(renderer) {
|
|
433
|
+
let n = 0;
|
|
434
|
+
for (const p of Object.values(_treeTexCache)) { try { renderer.initTexture(await p); n++; } catch (e) { /* skip */ } }
|
|
435
|
+
return n;
|
|
436
|
+
}
|
|
437
|
+
// Split by per-triangle UV SPAN (not centroid): Synty foliage LEAF cards map a
|
|
438
|
+
// whole needle sprite → large UV bbox; TRUNK/branch faces sample a tiny flat bark
|
|
439
|
+
// spot → small UV bbox. Centroid-u splitting misclassified leaf cards straddling
|
|
440
|
+
// low-u (they sampled the bark atlas → rainbow branches). `splitU` is the span
|
|
441
|
+
// threshold: span > splitU → leaf sprite, else → trunk/branch bark. Proven in
|
|
442
|
+
// /treetest.html — see [[nature-biome-trees]].
|
|
443
|
+
function splitGeoByUV(geo, splitU) {
|
|
444
|
+
const uvA = geo.attributes.uv, idx = geo.index;
|
|
445
|
+
const n = idx ? idx.count : geo.attributes.position.count;
|
|
446
|
+
const trunk = [], leaf = [];
|
|
447
|
+
for (let i = 0; i < n; i += 3) {
|
|
448
|
+
const vi = (k) => idx ? idx.getX(i + k) : i + k;
|
|
449
|
+
let umin = 9, umax = -9, vmin = 9, vmax = -9;
|
|
450
|
+
for (let k = 0; k < 3; k++) { const u = uvA.getX(vi(k)), v = uvA.getY(vi(k)); umin = Math.min(umin, u); umax = Math.max(umax, u); vmin = Math.min(vmin, v); vmax = Math.max(vmax, v); }
|
|
451
|
+
const span = Math.max(umax - umin, vmax - vmin);
|
|
452
|
+
(span > splitU ? leaf : trunk).push(vi(0), vi(1), vi(2));
|
|
453
|
+
}
|
|
454
|
+
const mk = (ind) => { const g = geo.clone(); g.setIndex(ind); return g; };
|
|
455
|
+
return { trunkGeo: trunk.length ? mk(trunk) : null, leafGeo: leaf.length ? mk(leaf) : null };
|
|
456
|
+
}
|
|
457
|
+
// Merge same-LOD meshes (world transforms applied) into ONE geometry, keeping only
|
|
458
|
+
// position/normal/uv (matches flattenToGeometry's attribute policy so mergeGeometries
|
|
459
|
+
// never trips on mismatched attribute sets).
|
|
460
|
+
function mergeMeshGeos(meshes) {
|
|
461
|
+
const geos = [];
|
|
462
|
+
for (const o of meshes) {
|
|
463
|
+
const g = o.geometry.clone();
|
|
464
|
+
g.applyMatrix4(o.matrixWorld);
|
|
465
|
+
for (const key of Object.keys(g.attributes)) if (!['position', 'normal', 'uv'].includes(key)) g.deleteAttribute(key);
|
|
466
|
+
if (!g.attributes.uv) g.setAttribute('uv', new THREE.BufferAttribute(new Float32Array(g.attributes.position.count * 2), 2));
|
|
467
|
+
geos.push(g);
|
|
468
|
+
}
|
|
469
|
+
if (!geos.length) return null;
|
|
470
|
+
return geos.length === 1 ? geos[0] : BufferGeometryUtils.mergeGeometries(geos, false);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Build (and cache) the per-LOD split geometry for a biome tree. The Synty FBX ships
|
|
474
|
+
// LOD0..LODn; the terminal ~12-tri LOD is a crossed BILLBOARD card that samples its
|
|
475
|
+
// own pre-rendered atlas (not the trunk/leaf sprite atlas) so it's kept whole. Every
|
|
476
|
+
// real mesh LOD is split trunk-vs-leaf by UV span. Returns
|
|
477
|
+
// [{ level, tris, trunkGeo, leafGeo, cardGeo }] (only one of {trunk+leaf}|card set)
|
|
478
|
+
// All geos are userData.cached so disposeScatterGroup never frees them (shared across
|
|
479
|
+
// every streamed tile that stamps this tree — one geometry set, many InstancedMeshes).
|
|
480
|
+
const CARD_TRI_MAX = 64;
|
|
481
|
+
const _treeTierCache = new Map();
|
|
482
|
+
function treeLODTiers(cacheKey, group, splitU) {
|
|
483
|
+
let tiers = _treeTierCache.get(cacheKey);
|
|
484
|
+
if (tiers) return tiers;
|
|
485
|
+
group.updateMatrixWorld(true);
|
|
486
|
+
const byLevel = new Map();
|
|
487
|
+
group.traverse((o) => {
|
|
488
|
+
if (!o.isMesh) return;
|
|
489
|
+
const lv = +((o.name.match(/_LOD(\d+)/i) || [])[1] ?? 0);
|
|
490
|
+
if (!byLevel.has(lv)) byLevel.set(lv, []);
|
|
491
|
+
byLevel.get(lv).push(o);
|
|
492
|
+
});
|
|
493
|
+
tiers = [];
|
|
494
|
+
for (const lv of [...byLevel.keys()].sort((a, b) => a - b)) {
|
|
495
|
+
const geo = mergeMeshGeos(byLevel.get(lv));
|
|
496
|
+
if (!geo) continue;
|
|
497
|
+
const tris = (geo.index ? geo.index.count : geo.attributes.position.count) / 3;
|
|
498
|
+
let trunkGeo = null, leafGeo = null, cardGeo = null;
|
|
499
|
+
if (tris <= CARD_TRI_MAX) {
|
|
500
|
+
cardGeo = geo; // billboard card — no split
|
|
501
|
+
} else {
|
|
502
|
+
({ trunkGeo, leafGeo } = splitGeoByUV(geo, splitU));
|
|
503
|
+
geo.dispose?.(); // split clones out; drop the merged source
|
|
504
|
+
}
|
|
505
|
+
for (const g of [trunkGeo, leafGeo, cardGeo]) if (g) g.userData.cached = true;
|
|
506
|
+
tiers.push({ level: lv, tris, trunkGeo, leafGeo, cardGeo });
|
|
507
|
+
}
|
|
508
|
+
_treeTierCache.set(cacheKey, tiers);
|
|
509
|
+
return tiers;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Per-tree-type hard-swap switch distances (m) by tier count. Tuned so the near
|
|
513
|
+
// full-detail mesh only shows close, then the reduced meshes, then the 12-tri
|
|
514
|
+
// billboard takes over in the deep distance where a flat card is imperceptible.
|
|
515
|
+
const TREE_DISTS = { 1: [], 2: [70], 3: [50, 110], 4: [46, 88, 135], 5: [42, 78, 118, 165] };
|
|
516
|
+
|
|
517
|
+
// Tree tier materials — shared via cachedMat between scatterTree and scatterTreeResident.
|
|
518
|
+
const treeCardMat = (cardTex, alphaTest) => cachedMat(`treecard|${cardTex.uuid}|${alphaTest}`, () => { const m = new THREE.MeshStandardMaterial({ map: cardTex, roughness: 1, metalness: 0, side: THREE.DoubleSide }); m.alphaTest = Math.min(alphaTest, 0.4); m.transparent = false; return m; });
|
|
519
|
+
const treeTrunkMat = (trunkTex) => cachedMat(`treetrunk|${trunkTex.uuid}`, () => new THREE.MeshStandardMaterial({ map: trunkTex, roughness: 0.9, metalness: 0 }));
|
|
520
|
+
const treeLeafMat = (leafTex, alphaTest) => cachedMat(`treeleaf|${leafTex.uuid}|${alphaTest}`, () => { const m = new THREE.MeshStandardMaterial({ map: leafTex, roughness: 0.9, metalness: 0, side: THREE.DoubleSide }); m.alphaTest = alphaTest; m.transparent = false; return m; });
|
|
521
|
+
|
|
522
|
+
// Synty Nature-Biomes tree: builds a per-tree LOD chain from the FBX's OWN LOD meshes.
|
|
523
|
+
// Each real mesh LOD → two InstancedMeshes (opaque trunk + alphaTest leaf, UV-split);
|
|
524
|
+
// the terminal billboard LOD → one InstancedMesh on the card atlas. Instances hard-swap
|
|
525
|
+
// between tiers by camera distance (updateTreeLODs), so a dense Scotland forest costs a
|
|
526
|
+
// handful of near LOD0 trees + cheap cards for everything beyond. Returns a Group for
|
|
527
|
+
// the tile system. cardTexUrl may be null (billboard tier is then dropped).
|
|
528
|
+
export async function scatterTree(url, trunkTexUrl, leafTexUrl, cardTexUrl, placements, { splitU = 0.12, alphaTest = 0.5, castShadow = true, dists = null } = {}) {
|
|
529
|
+
const master = await loadModel(url, { texture: null, twin: true }); // no atlas (per-part textures below) — twin ok: geometry is UV-split, file materials discarded
|
|
530
|
+
const tierGeos = treeLODTiers(url + '#tree', master, splitU);
|
|
531
|
+
if (!tierGeos.length) throw new Error(`no geometry in ${url}`);
|
|
532
|
+
const isTwin = /\.glb$/i.test(await resolveModelUrl(url, { twin: true }));
|
|
533
|
+
const [trunkTex, leafTex, cardTex] = await Promise.all([
|
|
534
|
+
trunkTexUrl ? treeTex(trunkTexUrl, 'trunk', isTwin) : null,
|
|
535
|
+
treeTex(leafTexUrl, 'leaf', isTwin),
|
|
536
|
+
cardTexUrl ? treeTex(cardTexUrl, 'card', isTwin) : null,
|
|
537
|
+
]);
|
|
538
|
+
const base = url.split('/').pop();
|
|
539
|
+
const N = placements.length;
|
|
540
|
+
const mats = composeMatrices(placements);
|
|
541
|
+
const group = new THREE.Group();
|
|
542
|
+
group.name = `tree:${base}`;
|
|
543
|
+
|
|
544
|
+
const mkInst = (geo, mat, tag, ti) => {
|
|
545
|
+
const mesh = new THREE.InstancedMesh(geo, mat, N);
|
|
546
|
+
mesh.instanceMatrix.array.set(mats);
|
|
547
|
+
mesh.count = ti === 0 ? N : 0; // tier0 shows all until the first rebucket
|
|
548
|
+
mesh.instanceMatrix.needsUpdate = true;
|
|
549
|
+
mesh.castShadow = castShadow && ti === 0; // only the near tier casts shadows
|
|
550
|
+
mesh.receiveShadow = true;
|
|
551
|
+
mesh.name = `scatter:${base}:L${ti}:${tag}`;
|
|
552
|
+
mesh.matrixAutoUpdate = false; mesh.updateMatrix(); // origin-placed, instances carry transforms
|
|
553
|
+
group.add(mesh);
|
|
554
|
+
return mesh;
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
const tiers = [];
|
|
558
|
+
tierGeos.forEach((tg, ti) => {
|
|
559
|
+
const tier = {};
|
|
560
|
+
if (tg.cardGeo) {
|
|
561
|
+
if (!cardTex) return; // billboard LOD with no card atlas → drop the tier
|
|
562
|
+
tier.card = mkInst(tg.cardGeo, treeCardMat(cardTex, alphaTest), 'card', ti);
|
|
563
|
+
} else {
|
|
564
|
+
if (tg.trunkGeo && trunkTex) tier.trunk = mkInst(tg.trunkGeo, treeTrunkMat(trunkTex), 'trunk', ti);
|
|
565
|
+
if (tg.leafGeo) tier.leaf = mkInst(tg.leafGeo, treeLeafMat(leafTex, alphaTest), 'leaf', ti);
|
|
566
|
+
}
|
|
567
|
+
if (tier.trunk || tier.leaf || tier.card) tiers.push(tier);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
const d = dists ?? TREE_DISTS[tiers.length] ?? Array.from({ length: Math.max(0, tiers.length - 1) }, (_, i) => 50 + i * 45);
|
|
571
|
+
const entry = { tiers, matrices: mats, positions: flatXZ(placements), count: N, dists: d };
|
|
572
|
+
const sph = groupSphere(placements, 16); // trees are tall — generous margin
|
|
573
|
+
for (const t of tiers) for (const m of [t.trunk, t.leaf, t.card]) if (m) m.boundingSphere = sph;
|
|
574
|
+
treeLodGroups.push(entry);
|
|
575
|
+
markLodDirty(); // fresh group starts tier0-everything — force the next rebucket cycle
|
|
576
|
+
group.userData.treeLodEntry = entry;
|
|
577
|
+
return group;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// RESIDENT biome tree: scatterTree's tiers (trunk+leaf per LOD + billboard card) at window
|
|
581
|
+
// capacity, born empty, slice-streamed. Trees collide via their per-placement trunk circles
|
|
582
|
+
// only (never the tile BVH — same as the per-tile builds), so no collider geometry is exposed.
|
|
583
|
+
export async function scatterTreeResident(url, trunkTexUrl, leafTexUrl, cardTexUrl, cap, allPl, { splitU = 0.12, alphaTest = 0.5, castShadow = true, dists = null } = {}) {
|
|
584
|
+
const master = await loadModel(url, { texture: null, twin: true }); // no atlas (per-part textures) — twin ok, see scatterTree
|
|
585
|
+
const tierGeos = treeLODTiers(url + '#tree', master, splitU);
|
|
586
|
+
if (!tierGeos.length) throw new Error(`no geometry in ${url}`);
|
|
587
|
+
const isTwin = /\.glb$/i.test(await resolveModelUrl(url, { twin: true }));
|
|
588
|
+
const [trunkTex, leafTex, cardTex] = await Promise.all([
|
|
589
|
+
trunkTexUrl ? treeTex(trunkTexUrl, 'trunk', isTwin) : null,
|
|
590
|
+
treeTex(leafTexUrl, 'leaf', isTwin),
|
|
591
|
+
cardTexUrl ? treeTex(cardTexUrl, 'card', isTwin) : null,
|
|
592
|
+
]);
|
|
593
|
+
const base = url.split('/').pop();
|
|
594
|
+
const group = new THREE.Group();
|
|
595
|
+
group.name = `tree:${base}`;
|
|
596
|
+
const sph = groupSphere(allPl, 16);
|
|
597
|
+
|
|
598
|
+
const mkInst = (geo, mat, tag, ti) => {
|
|
599
|
+
const mesh = new THREE.InstancedMesh(geo, mat, cap); // instanceMatrix born zero-filled (warm-flash safe)
|
|
600
|
+
mesh.count = 0;
|
|
601
|
+
mesh.castShadow = castShadow && ti === 0; // only the near tier casts shadows
|
|
602
|
+
mesh.receiveShadow = true;
|
|
603
|
+
mesh.name = `scatter:${base}:L${ti}:${tag}`;
|
|
604
|
+
mesh.boundingSphere = sph;
|
|
605
|
+
mesh.matrixAutoUpdate = false; mesh.updateMatrix(); // origin-placed, instances carry transforms
|
|
606
|
+
group.add(mesh);
|
|
607
|
+
return mesh;
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
const tiers = [];
|
|
611
|
+
tierGeos.forEach((tg, ti) => {
|
|
612
|
+
const tier = {};
|
|
613
|
+
if (tg.cardGeo) {
|
|
614
|
+
if (!cardTex) return;
|
|
615
|
+
tier.card = mkInst(tg.cardGeo, treeCardMat(cardTex, alphaTest), 'card', ti);
|
|
616
|
+
} else {
|
|
617
|
+
if (tg.trunkGeo && trunkTex) tier.trunk = mkInst(tg.trunkGeo, treeTrunkMat(trunkTex), 'trunk', ti);
|
|
618
|
+
if (tg.leafGeo) tier.leaf = mkInst(tg.leafGeo, treeLeafMat(leafTex, alphaTest), 'leaf', ti);
|
|
619
|
+
}
|
|
620
|
+
if (tier.trunk || tier.leaf || tier.card) tiers.push(tier);
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
const d = dists ?? TREE_DISTS[tiers.length] ?? Array.from({ length: Math.max(0, tiers.length - 1) }, (_, i) => 50 + i * 45);
|
|
624
|
+
const matrices = new Float32Array(cap * 16), positions = new Float32Array(cap * 2);
|
|
625
|
+
const entry = { tiers, matrices, positions, count: 0, dists: d };
|
|
626
|
+
treeLodGroups.push(entry); // registered EMPTY (count=0) until tiles write slices
|
|
627
|
+
group.userData.treeLodEntry = entry;
|
|
628
|
+
|
|
629
|
+
const slices = new SliceTable(group.name, cap, [{ a: matrices, s: 16 }, { a: positions, s: 2 }]);
|
|
630
|
+
return {
|
|
631
|
+
group,
|
|
632
|
+
colliderName: null,
|
|
633
|
+
colliderGeometry: null, // trunk circles only
|
|
634
|
+
write(key, pl) {
|
|
635
|
+
const off = slices.alloc(key, pl.length);
|
|
636
|
+
if (off < 0) return null;
|
|
637
|
+
composeInto(matrices, off, pl);
|
|
638
|
+
for (let i = 0; i < pl.length; i++) { positions[(off + i) * 2] = pl[i].x; positions[(off + i) * 2 + 1] = pl[i].z; }
|
|
639
|
+
entry.count = slices.count;
|
|
640
|
+
markLodDirty();
|
|
641
|
+
return true; // success — but trees contribute nothing to the tile BVH (trunk circles only)
|
|
642
|
+
},
|
|
643
|
+
free(key) {
|
|
644
|
+
if (!slices.free(key)) return;
|
|
645
|
+
entry.count = slices.count;
|
|
646
|
+
markLodDirty();
|
|
647
|
+
},
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// ---- biome-tree LOD registry: mirrors lodGroups/updateScatterLODs, but each tier
|
|
652
|
+
// can carry a PAIR of instanced meshes (trunk + leaf) plus the billboard card, and
|
|
653
|
+
// swaps are HARD (no dither) — alphaTest cutouts + alphaHash dissolve fight over the
|
|
654
|
+
// same alpha, and a hard pop on a dense conifer at its switch distance is invisible.
|
|
655
|
+
const treeLodGroups = [];
|
|
656
|
+
let _treeLodCursor = 0;
|
|
657
|
+
export function updateTreeLODs(px, pz) {
|
|
658
|
+
const n = treeLodGroups.length;
|
|
659
|
+
if (!n) return;
|
|
660
|
+
// instances beyond the camera far plane are clipped anyway — skip them entirely so the
|
|
661
|
+
// GPU never processes their vertices (residents hold a whole 5×5 window; without this the
|
|
662
|
+
// out-of-range majority still drew: 12.9M tris mid-countryside at LOW's 200m far plane)
|
|
663
|
+
const cull = (getQuality().cameraFar ?? 800) + 30;
|
|
664
|
+
const slice = Math.ceil(n / 3); // amortize: a third of the groups per call
|
|
665
|
+
const start = _treeLodCursor % n;
|
|
666
|
+
_treeLodCursor = (start + slice) % n;
|
|
667
|
+
for (let gi = 0; gi < slice; gi++) {
|
|
668
|
+
const g = treeLodGroups[(start + gi) % n];
|
|
669
|
+
const T = g.tiers.length;
|
|
670
|
+
const counts = new Array(T).fill(0);
|
|
671
|
+
for (let i = 0; i < g.count; i++) {
|
|
672
|
+
const dx = g.positions[i * 2] - px, dz = g.positions[i * 2 + 1] - pz; // flat [x,z] pairs
|
|
673
|
+
const dist = Math.sqrt(dx * dx + dz * dz);
|
|
674
|
+
if (dist > cull) continue;
|
|
675
|
+
let t = 0; while (t < g.dists.length && dist >= g.dists[t]) t++;
|
|
676
|
+
if (t >= T) t = T - 1; // instances past the last edge → farthest tier
|
|
677
|
+
const k = counts[t]++;
|
|
678
|
+
const tier = g.tiers[t];
|
|
679
|
+
const src = g.matrices.subarray(i * 16, i * 16 + 16);
|
|
680
|
+
if (tier.trunk) tier.trunk.instanceMatrix.array.set(src, k * 16);
|
|
681
|
+
if (tier.leaf) tier.leaf.instanceMatrix.array.set(src, k * 16);
|
|
682
|
+
if (tier.card) tier.card.instanceMatrix.array.set(src, k * 16);
|
|
683
|
+
}
|
|
684
|
+
for (let t = 0; t < T; t++) {
|
|
685
|
+
const tier = g.tiers[t];
|
|
686
|
+
for (const m of [tier.trunk, tier.leaf, tier.card]) {
|
|
687
|
+
if (!m) continue;
|
|
688
|
+
m.count = counts[t];
|
|
689
|
+
m.visible = counts[t] > 0; // skip count:0 tiers from the per-frame render-list walk
|
|
690
|
+
m.instanceMatrix.needsUpdate = true;
|
|
691
|
+
// boundingSphere: the conservative whole-group sphere assigned at build covers every
|
|
692
|
+
// bucketing, so no per-rebucket computeBoundingSphere (a full matrix pass per tier).
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const _cm = new THREE.Matrix4(), _cq = new THREE.Quaternion(), _cUP = new THREE.Vector3(0, 1, 0);
|
|
699
|
+
const _cv = new THREE.Vector3(), _cs = new THREE.Vector3();
|
|
700
|
+
// Compose placement transforms into `arr` starting at instance `offset` (16 floats/instance).
|
|
701
|
+
function composeInto(arr, offset, placements) {
|
|
702
|
+
for (let i = 0; i < placements.length; i++) {
|
|
703
|
+
const p = placements[i];
|
|
704
|
+
_cq.setFromAxisAngle(_cUP, p.ry ?? 0);
|
|
705
|
+
_cm.compose(_cv.set(p.x, p.y, p.z), _cq, _cs.setScalar(p.s ?? 1));
|
|
706
|
+
arr.set(_cm.elements, (offset + i) * 16);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
function composeMatrices(placements) {
|
|
710
|
+
const arr = new Float32Array(placements.length * 16);
|
|
711
|
+
composeInto(arr, 0, placements);
|
|
712
|
+
return arr;
|
|
713
|
+
}
|
|
714
|
+
// Flat [x0,z0, x1,z1, …] positions — the rebucketers' distance source. Flat (not array-of-pairs)
|
|
715
|
+
// so resident entries can copyWithin it during slice compaction.
|
|
716
|
+
function flatXZ(placements) {
|
|
717
|
+
const a = new Float32Array(placements.length * 2);
|
|
718
|
+
for (let i = 0; i < placements.length; i++) { a[i * 2] = placements[i].x; a[i * 2 + 1] = placements[i].z; }
|
|
719
|
+
return a;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// ---- RESIDENT scatter: the slice-table allocator --------------------------------
|
|
723
|
+
// r184 WebGPU pays ~3 pipeline-cache entries per new InstancedMesh OBJECT (getNodeBuilderState +
|
|
724
|
+
// pipeline + bind-group) even with fully shared geometry/material/texture — per-tile mesh churn was
|
|
725
|
+
// the 150-500ms boundary hitch. Resident meshes are created ONCE (empty, zero-filled buffers) and
|
|
726
|
+
// streamed INTO: each tile owns a contiguous slice of the instance arrays; tile enter appends a
|
|
727
|
+
// slice, tile leave compacts it out (copyWithin over every parallel array). Capacity = the max any
|
|
728
|
+
// 5×5 tile window can hold (computed by TileField.buildResidents), so overflow means a bug — shout
|
|
729
|
+
// rather than clamp (silent overflow = vanishing forests).
|
|
730
|
+
class SliceTable {
|
|
731
|
+
constructor(name, cap, arrays) { this.name = name; this.cap = cap; this.count = 0; this.list = []; this.arrays = arrays; } // arrays: [{a: TypedArray, s: stride}]
|
|
732
|
+
alloc(key, len) {
|
|
733
|
+
if (this.count + len > this.cap) { console.error(`[resident] ${this.name}: slice overflow (${this.count}+${len} > cap ${this.cap}) — tile content dropped`); return -1; }
|
|
734
|
+
const offset = this.count;
|
|
735
|
+
this.list.push({ key, offset, len });
|
|
736
|
+
this.count += len;
|
|
737
|
+
return offset;
|
|
738
|
+
}
|
|
739
|
+
free(key) {
|
|
740
|
+
const i = this.list.findIndex((sl) => sl.key === key);
|
|
741
|
+
if (i < 0) return false; // idempotent — a tile disposed mid-build frees before its slice exists
|
|
742
|
+
const { offset, len } = this.list[i];
|
|
743
|
+
for (const { a, s } of this.arrays) a.copyWithin(offset * s, (offset + len) * s, this.count * s);
|
|
744
|
+
for (let j = i + 1; j < this.list.length; j++) this.list[j].offset -= len;
|
|
745
|
+
this.list.splice(i, 1);
|
|
746
|
+
this.count -= len;
|
|
747
|
+
return true;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// ---- octahedral-impostor billboard material (hard-swap tier; no distance fade) ----
|
|
752
|
+
// `center`/`size` are TSL nodes: instanced attributes for scatter, uniforms for a
|
|
753
|
+
// single building. Always camera-facing; samples the baked atlas by view angle.
|
|
754
|
+
function impostorMaterial(albedo, desc, flipV, center, size, tint = false) {
|
|
755
|
+
const mat = new THREE.MeshBasicNodeMaterial(); // unlit: the atlas already has light baked
|
|
756
|
+
mat.side = THREE.DoubleSide;
|
|
757
|
+
const grid = float(desc.grid);
|
|
758
|
+
|
|
759
|
+
// view-space billboard: instance centre → view space, offset by the quad corner
|
|
760
|
+
// along the camera-aligned X/Y axes, then project. Always faces the camera.
|
|
761
|
+
mat.vertexNode = Fn(() => {
|
|
762
|
+
const mv = modelViewMatrix.mul(vec4(center, 1.0));
|
|
763
|
+
const corner = positionGeometry.xy; // PlaneGeometry → [-0.5, 0.5]
|
|
764
|
+
const p = vec4(mv.x.add(corner.x.mul(size.x)), mv.y.add(corner.y.mul(size.y)), mv.z, float(1.0));
|
|
765
|
+
return cameraProjectionMatrix.mul(p);
|
|
766
|
+
})();
|
|
767
|
+
|
|
768
|
+
// hemi-octahedral cell from the model→camera direction (descriptor encoding)
|
|
769
|
+
const d = normalize(cameraPosition.sub(center));
|
|
770
|
+
const an = max(abs(d.x).add(abs(d.y)).add(abs(d.z)), float(1e-4));
|
|
771
|
+
const n = d.div(an);
|
|
772
|
+
const u = n.x.add(n.z).mul(0.5).add(0.5);
|
|
773
|
+
const v = n.z.sub(n.x).mul(0.5).add(0.5);
|
|
774
|
+
const cell = floor(vec2(u, v).mul(grid)).clamp(float(0), grid.sub(1));
|
|
775
|
+
// billboard top (uv.y=1) → tree top within its cell. flipV flips the WITHIN-cell V.
|
|
776
|
+
const qy = flipV ? oneMinus(uv().y) : uv().y;
|
|
777
|
+
const auv = cell.add(vec2(uv().x, qy)).div(grid);
|
|
778
|
+
const samp = texture(albedo, auv);
|
|
779
|
+
// the atlas has DAYLIGHT baked in — scale toward moonlit blue at night, or distant
|
|
780
|
+
// impostors glow brighter than the lit foreground ("distance brighter than close").
|
|
781
|
+
// Night floor tuned DOWN + moon-blue (was 0.30/0.34/0.48 — impostors read warm-bright
|
|
782
|
+
// against real walls lit by the 0x9fb4e0 moon); the day term keeps day at exactly 1.
|
|
783
|
+
let col = samp.rgb.mul(vec3(0.17, 0.21, 0.34).add(vec3(0.83, 0.79, 0.66).mul(uDayness)));
|
|
784
|
+
if (tint) col = col.mul(tintAt(center.xz)); // same world-keyed field as the mesh tiers — no pop at the swap
|
|
785
|
+
mat.colorNode = col;
|
|
786
|
+
mat.opacityNode = samp.a;
|
|
787
|
+
mat.alphaHash = true;
|
|
788
|
+
return mat;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// Cross-fade ramp for a tier: dither IN above nearEdge, OUT above farEdge (either
|
|
792
|
+
// may be null at the chain ends). `distNode` is per-fragment camera distance.
|
|
793
|
+
function tierRamp(nearEdge, farEdge, B, distNode) {
|
|
794
|
+
let ramp = null;
|
|
795
|
+
if (nearEdge != null) ramp = smoothstep(float(nearEdge - B), float(nearEdge + B), distNode);
|
|
796
|
+
if (farEdge != null) {
|
|
797
|
+
const out = oneMinus(smoothstep(float(farEdge - B), float(farEdge + B), distNode));
|
|
798
|
+
ramp = ramp ? ramp.mul(out) : out;
|
|
799
|
+
}
|
|
800
|
+
return ramp; // null → no fade (single-tier object)
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// Default tier switch distances (metres) by tier count — full mesh near, reduced
|
|
804
|
+
// glodify meshes through the mid range, impostor billboard in the deep distance.
|
|
805
|
+
const DEFAULT_DISTS = { 2: [62], 3: [48, 108], 4: [40, 80, 140], 5: [36, 68, 105, 145] }; // one notch nearer than the originals — a few ms of LOD0 tris back, swaps hidden by the dither fades
|
|
806
|
+
|
|
807
|
+
// Impostor descriptors were re-fetched on EVERY scatterChain call (per tile, pre-resident) —
|
|
808
|
+
// cache the JSON promise per URL. Descriptors are read-only.
|
|
809
|
+
const _impJsonCache = new Map();
|
|
810
|
+
function fetchImpJson(url) {
|
|
811
|
+
let p = _impJsonCache.get(url);
|
|
812
|
+
if (!p) { p = fetch(url).then((r) => r.json()); _impJsonCache.set(url, p); }
|
|
813
|
+
return p;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// PASS 1 (shared by scatterChain + scatterChainResident) — gather every mesh tier's GEOMETRY
|
|
817
|
+
// plus the impostor descriptor/albedo. Materials wait for pass 2: the LOD fade distance depends
|
|
818
|
+
// on the FINAL tier count, and each tier's material (fade baked in) is cached and SHARED across
|
|
819
|
+
// tiles. Fresh NodeMaterials per tile were the measured freeze source — every new node graph is
|
|
820
|
+
// a new pipeline cache key, so streamed tiles COMPILED pipelines mid-play (freeze profiler:
|
|
821
|
+
// pipe:+20 on a boundary) even though the boot warm had compiled this type.
|
|
822
|
+
async function chainParts(url, lodUrls, impJsonUrl, textureUrl, { lod = 0, tint = false } = {}) {
|
|
823
|
+
const _isGLB = /\.glb$/i.test(await resolveModelUrl(url, { texture: textureUrl }));
|
|
824
|
+
const [master, tex] = await Promise.all([loadModel(url, { texture: textureUrl }), loadTexture(textureUrl, _isGLB ? { flipY: false } : {})]);
|
|
825
|
+
const lod0 = flattenCached(url, master, lod);
|
|
826
|
+
if (!lod0) throw new Error(`no geometry for ${url}`);
|
|
827
|
+
const base = url.split('/').pop().replace(/\.fbx$/i, '');
|
|
828
|
+
const geoTiers = [{ geo: lod0.geometry, name: `scatter:${base}`, key: `chain0|${tex.uuid}|${tint}`, hasColor: false }];
|
|
829
|
+
for (const lu of (lodUrls || [])) {
|
|
830
|
+
try {
|
|
831
|
+
const scene = await loadGLB(lu); scene.scale.setScalar(0.01);
|
|
832
|
+
const flat = flattenCached(lu, scene, 0);
|
|
833
|
+
if (!flat) continue;
|
|
834
|
+
geoTiers.push({ geo: flat.geometry, name: `scatterLOD:${lu.split('/').pop()}`, key: `chainL|${flat.hasColor ? 'vc' : tex.uuid}|${tint}`, hasColor: flat.hasColor });
|
|
835
|
+
} catch { /* missing LOD glb → skip this tier */ }
|
|
836
|
+
}
|
|
837
|
+
let imp = null;
|
|
838
|
+
if (impJsonUrl) {
|
|
839
|
+
try {
|
|
840
|
+
const desc = await fetchImpJson(impJsonUrl);
|
|
841
|
+
const dir = impJsonUrl.slice(0, impJsonUrl.lastIndexOf('/'));
|
|
842
|
+
const albedo = await loadTexture(`${dir}/${desc.maps.albedo}`);
|
|
843
|
+
imp = { desc, albedo };
|
|
844
|
+
} catch { /* missing impostor → skip */ }
|
|
845
|
+
}
|
|
846
|
+
return { tex, base, geoTiers, imp };
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// PASS 2 tier material via the shared cache. ONLY the nearer tier dithers OUT at each boundary;
|
|
850
|
+
// the farther tier stays SOLID and is revealed through the holes (dithering both punches holes
|
|
851
|
+
// clean through → speckle). The fade opacityNode is baked INTO the cached material, keyed by
|
|
852
|
+
// (texture|vertexColor, tint, farEdge) — identical tiers across every caller share ONE
|
|
853
|
+
// material/pipeline, and a different fade distance can never leak between types.
|
|
854
|
+
function chainTierMat(g, tex, tint, farEdge, B) {
|
|
855
|
+
return cachedMat(`${g.key}|${farEdge}`, () => {
|
|
856
|
+
const mm = g.hasColor
|
|
857
|
+
? new THREE.MeshStandardNodeMaterial({ vertexColors: true, roughness: 1, metalness: 0 })
|
|
858
|
+
: new THREE.MeshStandardNodeMaterial({ map: tex, roughness: 0.9, metalness: 0 });
|
|
859
|
+
if (tint) mm.colorNode = (g.hasColor ? attribute('color', 'vec3') : texture(tex, uv()).rgb).mul(tintAt(positionWorld.xz));
|
|
860
|
+
if (farEdge != null) { mm.opacityNode = oneMinus(smoothstep(float(farEdge - B), float(farEdge + B), camDist)); mm.alphaHash = true; }
|
|
861
|
+
return mm;
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// Billboard quad with per-instance centre/size attributes sized to `cap` (zero-filled).
|
|
866
|
+
function impQuad(cap) {
|
|
867
|
+
const quad = new THREE.PlaneGeometry(1, 1);
|
|
868
|
+
quad.setAttribute('iCenter', new THREE.InstancedBufferAttribute(new Float32Array(cap * 3), 3));
|
|
869
|
+
quad.setAttribute('iSize', new THREE.InstancedBufferAttribute(new Float32Array(cap * 2), 2));
|
|
870
|
+
return quad;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// Multi-tier LOD chain: LOD0 (full FBX) → LOD1/2/3 (glodify meshes) → LOD4 impostor.
|
|
874
|
+
// Instances hard-swap between tiers by camera distance (updateScatterLODs). lodUrls
|
|
875
|
+
// = ordered [LOD1.glb, LOD2.glb, LOD3.glb] (missing ones are skipped); impJsonUrl
|
|
876
|
+
// optional (adds the impostor as the farthest tier).
|
|
877
|
+
export async function scatterChain(url, lodUrls, impJsonUrl, textureUrl, placements, { castShadow = true, lod = 0, dists, flipV = false, tint = false } = {}) {
|
|
878
|
+
const { tex, base, geoTiers, imp } = await chainParts(url, lodUrls, impJsonUrl, textureUrl, { lod, tint });
|
|
879
|
+
const N = placements.length;
|
|
880
|
+
const matrices = composeMatrices(placements);
|
|
881
|
+
const tierCount = geoTiers.length + (imp ? 1 : 0);
|
|
882
|
+
const d = dists ?? DEFAULT_DISTS[tierCount] ?? Array.from({ length: tierCount - 1 }, (_, i) => 50 + i * 50);
|
|
883
|
+
const B = 14; // cross-fade half-width (m)
|
|
884
|
+
|
|
885
|
+
const tiers = [];
|
|
886
|
+
geoTiers.forEach((g, t) => {
|
|
887
|
+
const mesh = new THREE.InstancedMesh(g.geo, chainTierMat(g, tex, tint, t < d.length ? d[t] : null, B), N);
|
|
888
|
+
mesh.castShadow = t === 0 && castShadow; mesh.receiveShadow = true; mesh.name = g.name;
|
|
889
|
+
mesh.instanceMatrix.array.set(matrices); mesh.count = 0;
|
|
890
|
+
tiers.push({ mesh });
|
|
891
|
+
});
|
|
892
|
+
if (imp) {
|
|
893
|
+
const quad = impQuad(N);
|
|
894
|
+
const iCenter = quad.getAttribute('iCenter').array, iSize = quad.getAttribute('iSize').array;
|
|
895
|
+
const srcCenter = new Float32Array(N * 3), srcSize = new Float32Array(N * 2);
|
|
896
|
+
const S = 0.01, bw = imp.desc.billboardSize * S, cy = (imp.desc.bounds?.center?.[1] ?? 0) * S;
|
|
897
|
+
placements.forEach((p, i) => {
|
|
898
|
+
const sc = p.s ?? 1;
|
|
899
|
+
srcCenter[i * 3] = p.x; srcCenter[i * 3 + 1] = (p.y ?? 0) + cy * sc; srcCenter[i * 3 + 2] = p.z;
|
|
900
|
+
srcSize[i * 2] = bw * sc; srcSize[i * 2 + 1] = bw * sc;
|
|
901
|
+
});
|
|
902
|
+
iCenter.set(srcCenter); iSize.set(srcSize); // initial: show all as impostors until first rebucket
|
|
903
|
+
const impMat = cachedMat(`imp|${imp.albedo.uuid}|${flipV}|${tint}`, () => impostorMaterial(imp.albedo, imp.desc, flipV, attribute('iCenter', 'vec3'), attribute('iSize', 'vec2'), tint));
|
|
904
|
+
const impMesh = new THREE.InstancedMesh(quad, impMat, N);
|
|
905
|
+
impMesh.frustumCulled = false; impMesh.castShadow = false; impMesh.receiveShadow = false;
|
|
906
|
+
impMesh.count = N; impMesh.name = `impostor:${base}`;
|
|
907
|
+
tiers.push({ imp: impMesh, iCenter, iSize, srcCenter, srcSize });
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const entry = { tiers, matrices, positions: flatXZ(placements), count: N, dists: d, band: B };
|
|
911
|
+
const sph = groupSphere(placements);
|
|
912
|
+
for (const t of tiers) if (t.mesh) t.mesh.boundingSphere = sph; // (impostors are frustumCulled=false — no sphere needed)
|
|
913
|
+
lodGroups.push(entry);
|
|
914
|
+
markLodDirty(); // fresh chain shows impostors-everywhere until bucketed — force the next cycle
|
|
915
|
+
|
|
916
|
+
const group = new THREE.Group();
|
|
917
|
+
for (const t of tiers) { const m = t.mesh ?? t.imp; m.matrixAutoUpdate = false; m.updateMatrix(); group.add(m); } // origin-placed
|
|
918
|
+
group.userData.scatterEntry = entry; // lets disposeScatterGroup drop the lodGroups entry on tile unload
|
|
919
|
+
return group;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// RESIDENT chain: the same tiers/cached-materials as scatterChain, but capacity-sized, born
|
|
923
|
+
// empty, and slice-streamed — created ONCE at boot, no meshes ever created/destroyed at tile
|
|
924
|
+
// boundaries. New slices surface on the next rebucket cycle (markLodDirty): tiles enter at
|
|
925
|
+
// ≥360m, past the 320m max fog, so the ≤1s bucket latency is invisible.
|
|
926
|
+
export async function scatterChainResident(url, lodUrls, impJsonUrl, textureUrl, cap, allPl, { castShadow = true, lod = 0, dists, flipV = false, tint = false } = {}) {
|
|
927
|
+
const { tex, base, geoTiers, imp } = await chainParts(url, lodUrls, impJsonUrl, textureUrl, { lod, tint });
|
|
928
|
+
const tierCount = geoTiers.length + (imp ? 1 : 0);
|
|
929
|
+
const d = dists ?? DEFAULT_DISTS[tierCount] ?? Array.from({ length: tierCount - 1 }, (_, i) => 50 + i * 50);
|
|
930
|
+
const B = 14;
|
|
931
|
+
const matrices = new Float32Array(cap * 16); // zero-filled: the boot warm's count=1 flash draws a scale-0 instance
|
|
932
|
+
const positions = new Float32Array(cap * 2);
|
|
933
|
+
const sph = groupSphere(allPl);
|
|
934
|
+
const group = new THREE.Group();
|
|
935
|
+
|
|
936
|
+
const tiers = [];
|
|
937
|
+
geoTiers.forEach((g, t) => {
|
|
938
|
+
const mesh = new THREE.InstancedMesh(g.geo, chainTierMat(g, tex, tint, t < d.length ? d[t] : null, B), cap);
|
|
939
|
+
mesh.castShadow = t === 0 && castShadow; mesh.receiveShadow = true; mesh.name = g.name;
|
|
940
|
+
mesh.count = 0;
|
|
941
|
+
mesh.boundingSphere = sph;
|
|
942
|
+
mesh.matrixAutoUpdate = false; mesh.updateMatrix();
|
|
943
|
+
group.add(mesh);
|
|
944
|
+
tiers.push({ mesh });
|
|
945
|
+
});
|
|
946
|
+
let impData = null;
|
|
947
|
+
if (imp) {
|
|
948
|
+
const quad = impQuad(cap);
|
|
949
|
+
const S = 0.01;
|
|
950
|
+
impData = { srcCenter: new Float32Array(cap * 3), srcSize: new Float32Array(cap * 2), bw: imp.desc.billboardSize * S, cy: (imp.desc.bounds?.center?.[1] ?? 0) * S };
|
|
951
|
+
const impMat = cachedMat(`imp|${imp.albedo.uuid}|${flipV}|${tint}`, () => impostorMaterial(imp.albedo, imp.desc, flipV, attribute('iCenter', 'vec3'), attribute('iSize', 'vec2'), tint));
|
|
952
|
+
const impMesh = new THREE.InstancedMesh(quad, impMat, cap);
|
|
953
|
+
impMesh.frustumCulled = false; impMesh.castShadow = false; impMesh.receiveShadow = false;
|
|
954
|
+
impMesh.count = 0; impMesh.name = `impostor:${base}`;
|
|
955
|
+
impMesh.matrixAutoUpdate = false; impMesh.updateMatrix();
|
|
956
|
+
group.add(impMesh);
|
|
957
|
+
tiers.push({ imp: impMesh, iCenter: quad.getAttribute('iCenter').array, iSize: quad.getAttribute('iSize').array, srcCenter: impData.srcCenter, srcSize: impData.srcSize });
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
const entry = { tiers, matrices, positions, count: 0, dists: d, band: B };
|
|
961
|
+
lodGroups.push(entry); // registered EMPTY — the boot world-BVH bake reads entry.count (must be 0 until after it)
|
|
962
|
+
group.userData.scatterEntry = entry;
|
|
963
|
+
|
|
964
|
+
const arrays = [{ a: matrices, s: 16 }, { a: positions, s: 2 }];
|
|
965
|
+
if (impData) arrays.push({ a: impData.srcCenter, s: 3 }, { a: impData.srcSize, s: 2 });
|
|
966
|
+
const slices = new SliceTable(`chain:${base}`, cap, arrays);
|
|
967
|
+
return {
|
|
968
|
+
group,
|
|
969
|
+
colliderName: `scatter:${base}`,
|
|
970
|
+
colliderGeometry: geoTiers[0].geo, // LOD0 collides across the tile's FULL instance set (as before)
|
|
971
|
+
write(key, pl) {
|
|
972
|
+
const off = slices.alloc(key, pl.length);
|
|
973
|
+
if (off < 0) return null;
|
|
974
|
+
composeInto(matrices, off, pl);
|
|
975
|
+
for (let i = 0; i < pl.length; i++) { positions[(off + i) * 2] = pl[i].x; positions[(off + i) * 2 + 1] = pl[i].z; }
|
|
976
|
+
if (impData) {
|
|
977
|
+
for (let i = 0; i < pl.length; i++) {
|
|
978
|
+
const p = pl[i], sc = p.s ?? 1, j = off + i;
|
|
979
|
+
impData.srcCenter[j * 3] = p.x; impData.srcCenter[j * 3 + 1] = (p.y ?? 0) + impData.cy * sc; impData.srcCenter[j * 3 + 2] = p.z;
|
|
980
|
+
impData.srcSize[j * 2] = impData.bw * sc; impData.srcSize[j * 2 + 1] = impData.bw * sc;
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
entry.count = slices.count;
|
|
984
|
+
markLodDirty();
|
|
985
|
+
return matrices.slice(off * 16, (off + pl.length) * 16); // per-tile copy for the tile BVH (survives compaction)
|
|
986
|
+
},
|
|
987
|
+
free(key) {
|
|
988
|
+
if (!slices.free(key)) return;
|
|
989
|
+
entry.count = slices.count;
|
|
990
|
+
markLodDirty(); // stale tier packing self-heals on the next rebucket (the freed tile is behind fog)
|
|
991
|
+
},
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// Back-compat: 2-tier (LOD0 + one glodify LOD mesh), no impostor — used for paths.
|
|
996
|
+
export async function scatterLOD(url, lodUrl, textureUrl, placements, opts = {}) {
|
|
997
|
+
return scatterChain(url, [lodUrl], null, textureUrl, placements, opts);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// Full chain ending in an impostor. Which tiers actually get built is read per item
|
|
1001
|
+
// from lodConfig.json (edited in /lodconfig.html); LOD0 is always on.
|
|
1002
|
+
function impostorTierUrls(impJsonUrl) {
|
|
1003
|
+
const path = impJsonUrl.replace(/_LOD4_impostor\.json$/i, '');
|
|
1004
|
+
const name = path.split('/').pop();
|
|
1005
|
+
const cfg = LOD_CONFIG[name] ?? DEFAULT_TIERS;
|
|
1006
|
+
const lodUrls = [];
|
|
1007
|
+
if (cfg.includes('lod1')) lodUrls.push(`${path}_LOD1.glb`);
|
|
1008
|
+
if (cfg.includes('lod2')) lodUrls.push(`${path}_LOD2.glb`);
|
|
1009
|
+
if (cfg.includes('lod3')) lodUrls.push(`${path}_LOD3.glb`);
|
|
1010
|
+
return { lodUrls, imp: cfg.includes('impostor') ? impJsonUrl : null };
|
|
1011
|
+
}
|
|
1012
|
+
export async function scatterImpostor(url, impJsonUrl, textureUrl, placements, opts = {}) {
|
|
1013
|
+
const { lodUrls, imp } = impostorTierUrls(impJsonUrl);
|
|
1014
|
+
return scatterChain(url, lodUrls, imp, textureUrl, placements, opts);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// Resident variants of the back-compat wrappers (the zones manifest uses these).
|
|
1018
|
+
export async function scatterLODResident(url, lodUrl, textureUrl, cap, allPl, opts = {}) {
|
|
1019
|
+
return scatterChainResident(url, [lodUrl], null, textureUrl, cap, allPl, opts);
|
|
1020
|
+
}
|
|
1021
|
+
export async function scatterImpostorResident(url, impJsonUrl, textureUrl, cap, allPl, opts = {}) {
|
|
1022
|
+
const { lodUrls, imp } = impostorTierUrls(impJsonUrl);
|
|
1023
|
+
return scatterChainResident(url, lodUrls, imp, textureUrl, cap, allPl, opts);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// Rebucket every chain around the player: each instance lands in exactly one tier
|
|
1027
|
+
// by camera distance (hard swap). One pass of distance checks + row/attribute copies.
|
|
1028
|
+
let _lodCursor = 0;
|
|
1029
|
+
export function updateScatterLODs(px, pz) {
|
|
1030
|
+
// Amortized: rebucket a THIRD of the groups per call (round-robin). Doing all
|
|
1031
|
+
// of them in one frame every 0.35s was a visible saw-tooth spike; each group
|
|
1032
|
+
// still refreshes about once a second, well inside the cross-fade bands.
|
|
1033
|
+
const n = lodGroups.length;
|
|
1034
|
+
if (!n) return;
|
|
1035
|
+
const cull = (getQuality().cameraFar ?? 800) + 30; // beyond the far plane = clipped anyway — never bucket it
|
|
1036
|
+
const slice = Math.ceil(n / 3);
|
|
1037
|
+
const start = _lodCursor % n;
|
|
1038
|
+
_lodCursor = (start + slice) % n;
|
|
1039
|
+
for (let gi = 0; gi < slice; gi++) {
|
|
1040
|
+
const g = lodGroups[(start + gi) % n];
|
|
1041
|
+
const T = g.tiers.length, B = g.band ?? 0;
|
|
1042
|
+
const counts = new Array(T).fill(0);
|
|
1043
|
+
for (let i = 0; i < g.count; i++) {
|
|
1044
|
+
const dx = g.positions[i * 2] - px, dz = g.positions[i * 2 + 1] - pz; // flat [x,z] pairs
|
|
1045
|
+
const dist = Math.sqrt(dx * dx + dz * dz);
|
|
1046
|
+
if (dist > cull) continue;
|
|
1047
|
+
// an instance renders in every tier whose [nearEdge-B, farEdge+B] contains it —
|
|
1048
|
+
// so it appears in BOTH tiers across a boundary band and the ramps cross-fade.
|
|
1049
|
+
for (let t = 0; t < T; t++) {
|
|
1050
|
+
const lo = t > 0 ? g.dists[t - 1] - B : -Infinity;
|
|
1051
|
+
const hi = t < g.dists.length ? g.dists[t] + B : Infinity;
|
|
1052
|
+
if (dist < lo || dist > hi) continue;
|
|
1053
|
+
const tier = g.tiers[t], k = counts[t]++;
|
|
1054
|
+
if (tier.imp) {
|
|
1055
|
+
tier.iCenter.set(tier.srcCenter.subarray(i * 3, i * 3 + 3), k * 3);
|
|
1056
|
+
tier.iSize.set(tier.srcSize.subarray(i * 2, i * 2 + 2), k * 2);
|
|
1057
|
+
} else {
|
|
1058
|
+
tier.mesh.instanceMatrix.array.set(g.matrices.subarray(i * 16, i * 16 + 16), k * 16);
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
for (let t = 0; t < T; t++) {
|
|
1063
|
+
const tier = g.tiers[t];
|
|
1064
|
+
if (tier.imp) {
|
|
1065
|
+
tier.imp.count = counts[t];
|
|
1066
|
+
tier.imp.visible = counts[t] > 0; // count:0 impostor tiers are frustumCulled=false → skip the render-list walk
|
|
1067
|
+
tier.imp.geometry.getAttribute('iCenter').needsUpdate = true;
|
|
1068
|
+
tier.imp.geometry.getAttribute('iSize').needsUpdate = true;
|
|
1069
|
+
} else {
|
|
1070
|
+
tier.mesh.count = counts[t];
|
|
1071
|
+
tier.mesh.visible = counts[t] > 0;
|
|
1072
|
+
tier.mesh.instanceMatrix.needsUpdate = true;
|
|
1073
|
+
// boundingSphere: whole-group sphere assigned at build — no per-rebucket recompute.
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
}
|