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.
Files changed (65) hide show
  1. package/README.md +63 -2
  2. package/docs/api/README.md +28 -0
  3. package/docs/api/anim.md +125 -0
  4. package/docs/api/assets.md +208 -0
  5. package/docs/api/collision.md +101 -0
  6. package/docs/api/fbxloader.md +50 -0
  7. package/docs/api/grass.md +103 -0
  8. package/docs/api/input.md +164 -0
  9. package/docs/api/quality.md +159 -0
  10. package/docs/api/renderer.md +115 -0
  11. package/docs/api/retarget.md +149 -0
  12. package/docs/api/shatter.md +121 -0
  13. package/docs/api/utils-decals.md +188 -0
  14. package/docs/api/water.md +143 -0
  15. package/docs/api/wind-weather-uniforms.md +105 -0
  16. package/docs/contracts/render.md +32 -0
  17. package/docs/contracts/time-feel.md +52 -0
  18. package/package.json +1 -1
  19. package/src/core/anim.js +184 -0
  20. package/src/core/assets.js +596 -0
  21. package/src/core/engine.js +284 -0
  22. package/src/core/input/bindings.js +30 -0
  23. package/src/core/input/gamepad.js +92 -0
  24. package/src/core/input/index.js +62 -0
  25. package/src/core/input/keyboard.js +16 -0
  26. package/src/core/input/mouse.js +48 -0
  27. package/src/core/quality.js +5 -1
  28. package/src/core/renderer.js +104 -0
  29. package/src/core/retarget.js +282 -0
  30. package/src/index.js +19 -2
  31. package/src/systems/aim.js +114 -0
  32. package/src/systems/campfire.js +150 -0
  33. package/src/systems/climbing.js +136 -0
  34. package/src/systems/deadeye.js +332 -0
  35. package/src/systems/encounters.js +123 -0
  36. package/src/systems/entity.js +524 -0
  37. package/src/systems/fistCurl.js +38 -0
  38. package/src/systems/footsteps.js +161 -0
  39. package/src/systems/glass.js +67 -0
  40. package/src/systems/herd.js +277 -0
  41. package/src/systems/horse.js +361 -0
  42. package/src/systems/mountedTraveller.js +518 -0
  43. package/src/systems/nav.js +343 -0
  44. package/src/systems/npc.js +880 -0
  45. package/src/systems/pathFollow.js +107 -0
  46. package/src/systems/platform.js +484 -0
  47. package/src/systems/riding.js +580 -0
  48. package/src/systems/seatedRider.js +396 -0
  49. package/src/systems/skybirds.js +129 -0
  50. package/src/systems/trainCamera.js +414 -0
  51. package/src/systems/traveller.js +254 -0
  52. package/src/systems/tumbleweeds.js +92 -0
  53. package/src/systems/vat.js +327 -0
  54. package/src/systems/vfx.js +472 -0
  55. package/src/world/blobShadows.js +109 -0
  56. package/src/world/clouds.js +61 -0
  57. package/src/world/flora.js +87 -0
  58. package/src/world/footprints.js +117 -0
  59. package/src/world/lampField.js +58 -0
  60. package/src/world/lampGlow.js +92 -0
  61. package/src/world/scatter.js +1077 -0
  62. package/src/world/sky.js +363 -0
  63. package/src/world/tileManager.js +197 -0
  64. package/src/world/walkHumps.js +74 -0
  65. 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
+ }