@threenative/core 0.3.3 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/world.d.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { Object3D, Mesh, LOD, Vector3, Group, BufferGeometry } from 'three';
2
- import { I as IComputeDriven, a as IGPUReadbackSample } from './gpu-readback-CMklJs6r.js';
3
- import { I as IRendererLike } from './renderer-Cy4qeBOA.js';
4
- import { I as IAssetLoader } from './assets-CYKk2WTu.js';
1
+ import { Object3D, Mesh, LOD, Vector3, Material, Group, Camera, BufferGeometry } from 'three';
2
+ import { I as IComputeDriven, a as IGPUReadbackSample } from './gpu-readback-CqJEfWNQ.js';
3
+ import { I as IRendererLike } from './renderer-CfsS2hxi.js';
4
+ import { I as IAssetLoader } from './assets-CqvE429w.js';
5
5
  import 'three/webgpu';
6
6
 
7
7
  interface IWorldErosionOptions {
@@ -25,9 +25,27 @@ interface IWorldTileColliderInput {
25
25
  readonly tileX: number;
26
26
  readonly tileZ: number;
27
27
  }
28
+ /**
29
+ * One frame's allowance for admission work — the millisecond budget every path that puts streamed
30
+ * content on screen draws on, spent in bounded units rather than in one lump.
31
+ *
32
+ * `admit` is the whole contract: run one unit and charge the frame for it, or report that there was
33
+ * no room and run nothing. A caller that is refused leaves the work for a later frame instead of
34
+ * dropping it, so the budget decides *when* content is admitted, never *whether*.
35
+ *
36
+ * Nothing here decides what anything looks like; it decides only how much of it a frame may pay for.
37
+ */
38
+ interface IAdmissionBudget {
39
+ /**
40
+ * Run one unit of admission work, charging the frame's budget for it. `false` means the budget
41
+ * was spent and `work` was never called, so the caller must stop and resume on a later frame.
42
+ */
43
+ admit(work: () => void): boolean;
44
+ }
28
45
  interface IWorldTile {
29
46
  readonly bytes: number;
30
- readonly collider: IWorldTileCollider;
47
+ /** `undefined` for a resident tile outside `colliderRadius`, or when no factory was given. */
48
+ readonly collider: IWorldTileCollider | undefined;
31
49
  readonly field: Heightfield;
32
50
  readonly key: string;
33
51
  readonly lod: LOD;
@@ -53,6 +71,14 @@ interface IWorldTilesOptions {
53
71
  readonly assets?: Pick<IAssetLoader, "release">;
54
72
  /** A game-owned, already-loaded logical model key, or a key resolver per tile. */
55
73
  readonly assetKey?: string | ((tileX: number, tileZ: number) => string);
74
+ /**
75
+ * Chebyshev radius, in tiles from the followed one, that gets a `createCollider` body. Defaults
76
+ * to `streamRadius`, so every resident tile collides. A smaller radius keeps a wide render ring
77
+ * cheap to simulate: a tile crossing the radius has its collider created or disposed as it goes.
78
+ */
79
+ readonly colliderRadius?: number;
80
+ /** Terrain tiles and seam bridges receive the scene's shadows. Default false. */
81
+ readonly receiveShadow?: boolean;
56
82
  /** Creates the physics body from the field's explicit collider-order copy. */
57
83
  readonly createCollider?: (input: IWorldTileColliderInput) => IWorldTileCollider;
58
84
  /** TSL pass options are game supplied and are forwarded to each resident field. */
@@ -72,6 +98,20 @@ interface IWorldTilesOptions {
72
98
  readonly lodFactors?: readonly number[];
73
99
  /** Distances in world units at which the next LOD becomes active. */
74
100
  readonly lodDistances?: readonly number[];
101
+ /**
102
+ * Re-derive the terrain every frame and assert it: a finiteness scan of every rendered vertex,
103
+ * a seam measurement per resident pair and an LOD pop sample per blending tile. Off by default
104
+ * because it is a measurement and it cost ~270 ms over a six-second walk on a 289-tile ring.
105
+ * `TN_TERRAIN_VALIDATE=1` or `?tnTerrainValidate=1` turns it on for a run; this overrides that.
106
+ */
107
+ readonly validate?: boolean;
108
+ /**
109
+ * Merge settled same-LOD terrain tiles into super-tiles: one mesh per K×K block, rebuilt from the
110
+ * resident tiles through the admission budget. It changes how many draws the main pass submits,
111
+ * never how the ground looks — a merged vertex lands on the world position its tile's vertex did.
112
+ * Off by default; `mergeTiles: true`, `?tnTerrainMerge=1` or `TN_TERRAIN_MERGE=1` turns it on.
113
+ */
114
+ readonly mergeTiles?: boolean;
75
115
  /** Explicit game-owned measurement region used by the topology evaluator. */
76
116
  readonly topologyObservation?: IWorldTilesTopologyObservation;
77
117
  }
@@ -86,7 +126,8 @@ interface IWorldTilesOptions {
86
126
  * @alias stream terrain across chunks
87
127
  * @constraint sampleHeight and surface are required game choices; no landform or surface preset is installed
88
128
  * @constraint residentTileBudget and residentByteBudget are hard caps; a tile that cannot fit throws
89
- * @override tileSize, tileResolution, lodFactors, lodDistances, skirtDepth, streamRadius, and budgets
129
+ * @constraint seam gap, LOD pop and the rendered-vertex finiteness scan are measurements that are off by default; TN_TERRAIN_VALIDATE=1, ?tnTerrainValidate=1 or validate: true runs them, and maxSeamGap, maxVisualSeamGap and maxLodPop report undefined while they are off
130
+ * @override tileSize, tileResolution, lodFactors, lodDistances, skirtDepth, streamRadius, colliderRadius, validate, and budgets
90
131
  * @example const tiles = new TerrainTiles({ sampleHeight, surface: gameSurface(), tileSize: 256, tileResolution: 129, residentTileBudget: 25, residentByteBudget: 32_000_000 });
91
132
  */
92
133
  declare class TerrainTiles extends Object3D implements IComputeDriven {
@@ -107,19 +148,63 @@ declare class TerrainTiles extends Object3D implements IComputeDriven {
107
148
  get residentColliderKeys(): readonly string[];
108
149
  get lodLevelCount(): number;
109
150
  get lodTransitions(): number;
110
- /** Maximum per-render-frame displacement of the visible LOD surface during transitions. */
111
- get maxLodPop(): number;
151
+ /**
152
+ * What the main pass submits for terrain levels right now: `tiles` surfaces, held as `blocks`
153
+ * merged super-tiles plus one mesh each for the tiles that are not merged (a lone block member or
154
+ * a tile mid LOD morph). `draws` is `blocks` plus those individual meshes, so with the merge off
155
+ * it is exactly the number of visible level meshes — the census the merge exists to cut. `rebuilds`
156
+ * counts the block geometries built over this residency owner's life.
157
+ */
158
+ get terrainTiles(): {
159
+ readonly blocks: number;
160
+ readonly draws: number;
161
+ readonly rebuilds: number;
162
+ readonly tiles: number;
163
+ };
164
+ /**
165
+ * Resident tiles mid LOD morph right now, so `process` is still owed. A blend is
166
+ * `LOD_TRANSITION_FRAMES` frames long and only `process` advances it, so a caller that skips
167
+ * `process` while its follow point is standing still would freeze every blend on frame one.
168
+ */
169
+ get blendingTiles(): number;
170
+ /**
171
+ * Maximum per-render-frame displacement of the visible LOD surface during transitions.
172
+ *
173
+ * `undefined` when validation is off: the pop is sampled by measuring the rendered surface twice
174
+ * a frame, and reporting the empty value `0` instead of a measurement would be a lie a caller
175
+ * could assert on.
176
+ */
177
+ get maxLodPop(): number | undefined;
112
178
  /** Maximum number of rendered frames during which an LOD transition remained observable. */
113
179
  get maxLodTransitionFrames(): number;
114
- /** Maximum visible edge gap observed across follow/process calls for this residency owner. */
115
- get maxSeamGap(): number;
116
- /** Maximum remaining visible gap after skirt or bridge coverage observed across follow/process calls. */
117
- get maxVisualSeamGap(): number;
180
+ /**
181
+ * Maximum visible edge gap observed across follow/process calls for this residency owner, or
182
+ * `undefined` when validation is off — see `maxLodPop`.
183
+ */
184
+ get maxSeamGap(): number | undefined;
185
+ /**
186
+ * Maximum remaining visible gap after skirt or bridge coverage observed across follow/process
187
+ * calls, or `undefined` when validation is off — see `maxLodPop`.
188
+ */
189
+ get maxVisualSeamGap(): number | undefined;
118
190
  /** Number of mixed-LOD edge reconciliations observed during this residency lifetime. */
119
191
  get stitchedEdgeCount(): number;
192
+ /**
193
+ * A tile or a collider the last `follow` wanted and its budget refused, so the next one is owed
194
+ * work even for an unmoved follow point. `0` once a pass wanted nothing it did not get.
195
+ */
196
+ get deferredAdmissions(): number;
120
197
  get warmupNodes(): readonly unknown[];
121
198
  getTile(key: string): IWorldTile | undefined;
122
- follow(position: IWorldTilesFollowPosition | Pick<Vector3, "x" | "z">): void;
199
+ /**
200
+ * Move residency to the followed point: admit, evict and re-level every tile.
201
+ *
202
+ * `budget` caps what one call may admit. A tile the budget refuses is not resident this pass and
203
+ * costs nothing to try again, because `follow` recomputes the wanted set on every call — which is
204
+ * what makes this safe to defer: the tile was not drawn before either, so a refused admission
205
+ * leaves a gap rather than a hole. Omitted, a call admits everything it wants, as it always did.
206
+ */
207
+ follow(position: IWorldTilesFollowPosition | Pick<Vector3, "x" | "z">, budget?: IAdmissionBudget): void;
123
208
  heightAt(x: number, z: number): number;
124
209
  normalAt(x: number, z: number, target?: Vector3): Vector3;
125
210
  sample(channel: string, x: number, z: number): number;
@@ -130,6 +215,37 @@ declare class TerrainTiles extends Object3D implements IComputeDriven {
130
215
  dispose(): void;
131
216
  }
132
217
 
218
+ /**
219
+ * The oracle for terrain geometry that is otherwise trusted every frame.
220
+ *
221
+ * `TerrainTiles` re-derives what it already knows — every rendered vertex checked for finiteness,
222
+ * every resident seam re-measured, every LOD blend's visible displacement sampled — and that is a
223
+ * measurement, not a mechanism: on a 289-tile ring a six-second walk paid ~270 ms in three loops
224
+ * that only throw when something is already broken. So it is off by default and the shipped frame
225
+ * trusts the cheap change detector instead, which compares buffer versions and the residency and
226
+ * LOD state the class writes itself.
227
+ *
228
+ * `TN_TERRAIN_VALIDATE=1`, `?tnTerrainValidate=1`, or `validate: true` on the constructor puts the
229
+ * loops back and every assertion with them. It is a validation mode, not a proof: it proves the
230
+ * frames it ran on.
231
+ */
232
+ /** Marker printed when terrain validation is on, so a log says which mode produced it. */
233
+ declare const TERRAIN_VALIDATE_MARKER = "TN_TERRAIN_VALIDATE";
234
+ /** The launch flag. */
235
+ declare const TERRAIN_VALIDATE_FLAG = "TN_TERRAIN_VALIDATE";
236
+ /**
237
+ * Whether `TN_TERRAIN_VALIDATE` asks for terrain validation on this launch.
238
+ *
239
+ * @situation turn terrain's per-frame seam, LOD pop and vertex checks on for one run
240
+ * @situation assert the terrain geometry a game streams before it ships
241
+ * @constraint off by default: it is the work it checks, every frame
242
+ * @example // Reads its own the way `renderListValidationRequested` does: a native launch sets the
243
+ * // environment variable, a browser asks with the query string, a test or harness sets the
244
+ * // global. `0` and `false` are off, so a saved URL that enabled it still says "off".
245
+ * const tiles = new TerrainTiles({ ...options, validate: terrainValidationRequested() });
246
+ */
247
+ declare function terrainValidationRequested(): boolean;
248
+
133
249
  type WorldGenerationPath = "gpu" | "cpu-fallback" | "unsupported";
134
250
  interface IWorldComputeLimits {
135
251
  readonly maxComputeInvocationsPerWorkgroup: number;
@@ -171,6 +287,776 @@ interface IWorldCapabilitiesOptions {
171
287
  */
172
288
  declare function getWorldCapabilities(options?: IWorldCapabilitiesOptions): IWorldCapabilities;
173
289
 
290
+ /**
291
+ * The `world.json` package contract: a versioned manifest that names a raw heightmap, stable
292
+ * asset ids, placement runs and the square cells that stream them.
293
+ *
294
+ * Every value is authored by the world exporter and read by the runtime, so the game never
295
+ * hard-codes a cell layout or an asset path. Validation never throws on malformed JSON-shaped
296
+ * input: it collects named errors so an exporter can report every problem in one pass.
297
+ */
298
+ type WorldPackageErrorCode = "WORLD_VERSION_MISMATCH" | "WORLD_UNKNOWN_ASSET" | "WORLD_RUN_OUT_OF_RANGE" | "WORLD_CELL_OUTSIDE_EXTENT" | "WORLD_MALFORMED";
299
+ interface IWorldPackageError {
300
+ readonly code: WorldPackageErrorCode;
301
+ readonly message: string;
302
+ /** JSON-shaped path to the offending field, e.g. `cells[2].runs[0].offset`. */
303
+ readonly path: string;
304
+ }
305
+ interface IWorldExtent {
306
+ readonly minX: number;
307
+ readonly minZ: number;
308
+ readonly sizeX: number;
309
+ readonly sizeZ: number;
310
+ }
311
+ interface IWorldTerrain {
312
+ /** Package-relative path to the raw little-endian uint16 heightmap. */
313
+ readonly heightmap: string;
314
+ /** Vertex counts; `columns = sizeX / spacing + 1`, likewise `rows`. */
315
+ readonly columns: number;
316
+ readonly rows: number;
317
+ /** Metres between adjacent heightmap vertices on both axes. */
318
+ readonly spacing: number;
319
+ readonly heightMin: number;
320
+ readonly heightMax: number;
321
+ /** Optional opaque layer-mask paths, passed through to the game's surface untouched. */
322
+ readonly layers?: Readonly<Record<string, string>>;
323
+ }
324
+ interface IWorldAssetLod {
325
+ readonly glb: string;
326
+ readonly distance: number;
327
+ }
328
+ interface IWorldAssetBounds {
329
+ readonly min: readonly [number, number, number];
330
+ readonly max: readonly [number, number, number];
331
+ }
332
+ interface IWorldAsset {
333
+ readonly glb: string;
334
+ readonly lods?: readonly IWorldAssetLod[];
335
+ readonly bounds: IWorldAssetBounds;
336
+ readonly maxDistance?: number;
337
+ }
338
+ interface IWorldRun {
339
+ readonly asset: string;
340
+ /** Record offset into `placements`; one record is eight float32 values. */
341
+ readonly offset: number;
342
+ readonly count: number;
343
+ }
344
+ interface IWorldCell {
345
+ readonly x: number;
346
+ readonly z: number;
347
+ readonly runs: readonly IWorldRun[];
348
+ readonly chunks?: readonly string[];
349
+ }
350
+ interface IWorldPackage {
351
+ readonly version: 1;
352
+ readonly extent: IWorldExtent;
353
+ readonly cellSize: number;
354
+ readonly terrain: IWorldTerrain;
355
+ readonly assets: Readonly<Record<string, IWorldAsset>>;
356
+ readonly placements: string;
357
+ readonly cells: readonly IWorldCell[];
358
+ }
359
+ interface IWorldPackageValidationOptions {
360
+ /** Byte length of the placement buffer the runs index into. */
361
+ readonly placementsByteLength: number;
362
+ /** When known, the heightmap length must be `columns * rows * 2`. */
363
+ readonly heightmapByteLength?: number;
364
+ }
365
+ /**
366
+ * Validate a `world.json` manifest against the v1 contract.
367
+ *
368
+ * Never throws on garbage input: a non-object manifest is `WORLD_MALFORMED`. Every problem is
369
+ * collected, so an exporter sees the complete list at once.
370
+ *
371
+ * @situation check a Blender-exported world package before the runtime attaches anything
372
+ * @situation report why a world package cannot be streamed
373
+ * @constraint validation only checks structure and ranges; it never fetches the heightmap or GLBs
374
+ * @example const { ok, errors } = validateWorldPackage(json, { placementsByteLength: buffer.byteLength });
375
+ */
376
+ declare function validateWorldPackage(manifest: unknown, options: IWorldPackageValidationOptions): {
377
+ ok: boolean;
378
+ errors: IWorldPackageError[];
379
+ };
380
+ /**
381
+ * Borrow the run's placement records as a live view over the placement buffer.
382
+ *
383
+ * @situation feed one cell's instance transforms into a batch without copying
384
+ * @constraint the returned view aliases the caller's buffer; writing to it mutates the source
385
+ * @example const records = cellPlacements(buffer, { asset: "tree", offset: 0, count: 120 });
386
+ */
387
+ declare function cellPlacements(placements: ArrayBuffer, run: IWorldRun): Float32Array;
388
+
389
+ /**
390
+ * Build a game-usable `sampleHeight` from a raw v1 heightmap.
391
+ *
392
+ * The returned function interpolates bilinearly in world units and clamps to the map edges, so it
393
+ * plugs straight into `Heightfield.fromSampler` and `TerrainTiles`. Height is
394
+ * `heightMin + v / 65535 * (heightMax - heightMin)` at vertex `(column, row)`.
395
+ *
396
+ * @situation turn an exported raw heightmap into terrain collision and rendering
397
+ * @situation query ground height from a Blender-authored world package
398
+ * @constraint the sampler reads the game's data; the framework never selects a terrain shape
399
+ * @example const sampleHeight = heightSamplerFromHeightmap(terrain, extent, await loadWorldHeightmap(url));
400
+ */
401
+ declare function heightSamplerFromHeightmap(terrain: IWorldTerrain, extent: IWorldExtent, data: Uint16Array): (x: number, z: number) => number;
402
+ /**
403
+ * Fetch a raw little-endian uint16 heightmap and expose it as samples.
404
+ *
405
+ * @situation load a world package's heightmap once before building terrain
406
+ * @constraint a non-OK response throws; bytes are byte-swapped only on a big-endian host
407
+ * @example const data = await loadWorldHeightmap("/world/terrain/heightmap.u16");
408
+ */
409
+ declare function loadWorldHeightmap(url: string): Promise<Uint16Array>;
410
+
411
+ type Channel = "r" | "g" | "b" | "a";
412
+ /** One texture set: albedo (and optionally a normal map) tiled in metres, tinted in linear. */
413
+ interface ITerrainSplatLayer {
414
+ readonly id: string;
415
+ readonly tile: number;
416
+ readonly tint: readonly [number, number, number];
417
+ readonly normal?: boolean;
418
+ readonly saturation?: number;
419
+ /** Box-projected, for cliffs a top-down projection smears. */
420
+ readonly triplanar?: boolean;
421
+ }
422
+ /** A layer blended over what is below it by one mask channel, remapped from `lo..hi`. */
423
+ interface ITerrainSplatMaskedLayer extends ITerrainSplatLayer {
424
+ readonly mask: string;
425
+ /** The component of a `rgb` mask; ignored by a single-component mask. */
426
+ readonly channel: "r" | "g" | "b";
427
+ readonly lo: number;
428
+ readonly hi: number;
429
+ }
430
+ /**
431
+ * The package's terrain table (`terrain.layers.table`). Written by the export recipe
432
+ * `export_terrain_layers.py` from the game's own table; every value is the game's.
433
+ */
434
+ interface ITerrainSplatTable {
435
+ readonly base: ITerrainSplatLayer;
436
+ /** Noise (0..1 at `scale` per metre) that pushes every mask edge by +-`push`. */
437
+ readonly breakup: {
438
+ readonly scale: number;
439
+ readonly push: number;
440
+ };
441
+ /** Large-scale brightness variation against visible tiling, mapped to `min..max`. */
442
+ readonly macro: {
443
+ readonly scale: number;
444
+ readonly min: number;
445
+ readonly max: number;
446
+ };
447
+ readonly layers: readonly ITerrainSplatMaskedLayer[];
448
+ /**
449
+ * `terrain.layers.splat`: `planes` RGBA8 planes of `size`², rows bottom-up, covering the
450
+ * package extent. `masks` names each mask's plane and component (`rgb` = pick by the layer's
451
+ * `channel`).
452
+ */
453
+ readonly splat: {
454
+ readonly size: number;
455
+ readonly planes: number;
456
+ readonly masks: Readonly<Record<string, readonly [number, Channel | "rgb"]>>;
457
+ };
458
+ /** Package-relative folder of `<id>_diff.jpg` and `<id>_nrm.jpg`. */
459
+ readonly textures: string;
460
+ }
461
+ interface ILoadTerrainSplatOptions {
462
+ readonly assets: IAssetLoader;
463
+ /** The world package's `world.json`, as `WorldCells.load` takes it. */
464
+ readonly url: string;
465
+ }
466
+ /**
467
+ * The splat terrain surface a world package describes, for `WorldCells.load({ surface })`.
468
+ *
469
+ * Layers blend over a base by mask channels read as linear data (the masks ship raw, beside the
470
+ * heightmap, so no cook moves a blend threshold), with noise-broken edges and macro brightness
471
+ * variation. Texture sets tile in world metres on the package's ground plane (x, -z: a Z-up
472
+ * authoring tool's x and y), cliffs can be triplanar, and the base plus any layer that asks carries
473
+ * a normal map. Nothing here is a look choice: textures, tiles, tints, thresholds and noise scales
474
+ * all come from the package's table, which the game authors once and its DCC shares.
475
+ *
476
+ * @situation terrain textured by splat masks exported from Blender with the world package
477
+ * @situation the game's terrain should match the DCC's terrain material without a second copy
478
+ * @constraint the package's world.json must carry `terrain.layers.table` and `terrain.layers.splat`, written by the `export_terrain_layers.py` recipe
479
+ * @constraint WebGPU allows 16 sampled textures per stage: planes + diffuse maps + normal maps must fit
480
+ * @override every value comes from the package's table; the returned material is the game's to adjust
481
+ * @example
482
+ * const surface = await loadTerrainSplat({ assets: ctx.assets, url: "world/world.json" });
483
+ * const world = await WorldCells.load({ assets: ctx.assets, url: "world/world.json", surface, follow, ring: 2 });
484
+ */
485
+ declare function loadTerrainSplat(options: ILoadTerrainSplatOptions): Promise<Material>;
486
+
487
+ /** The point a streamed world follows; an `Object3D` satisfies this shape. */
488
+ interface IWorldCellsFollow {
489
+ readonly position: {
490
+ readonly x: number;
491
+ readonly z: number;
492
+ };
493
+ }
494
+ /** Hard caps on what stays resident. A cap that is reached reports pressure, it never throws. */
495
+ interface IWorldCellsBudget {
496
+ readonly residentCells: number;
497
+ readonly instances: number;
498
+ /** Placement-record bytes (`32` per instance) the resident cells are allowed to weigh. */
499
+ readonly bytes: number;
500
+ }
501
+ /** Terrain options forwarded to the composed `TerrainTiles`; `tileSize` defaults to `cellSize`. */
502
+ interface IWorldCellsTerrainOptions {
503
+ readonly tileSize?: number;
504
+ readonly tileResolution?: number;
505
+ readonly lodFactors?: readonly number[];
506
+ readonly lodDistances?: readonly number[];
507
+ readonly skirtDepth?: number;
508
+ /**
509
+ * Tiles kept resident around the follow tile, independent of the cell `ring`: terrain can reach
510
+ * to the horizon while props stay near. Defaults to `ring`, so raising only this widens the
511
+ * ground without widening the props, and the far tiles take the coarser `lodDistances` levels.
512
+ */
513
+ readonly streamRadius?: number;
514
+ /**
515
+ * Tiles that get a `createCollider` body, by Chebyshev radius from the follow tile. Defaults to
516
+ * `ring`. A tile entering or leaving it creates or disposes its collider, so a wide terrain
517
+ * radius does not have to mean a physics body for every tile in it.
518
+ */
519
+ readonly colliderRadius?: number;
520
+ }
521
+ interface IWorldCellsLoadOptions {
522
+ /**
523
+ * Logical path of `world.json` (`world/world.json`); every other path in the package resolves
524
+ * against it. A leading `/` is accepted and stripped, so an uncompiled `public/world` keeps
525
+ * working.
526
+ */
527
+ readonly url: string;
528
+ /**
529
+ * Loader the package's paths resolve through — its manifest, or the authored names when there is
530
+ * none. Defaults to a fresh `createAssetLoader()`; inside a game, pass `ctx.assets` so the
531
+ * package's compiled output and compressed textures reach the renderer the game booted with.
532
+ *
533
+ * The world releases the source models it asks this loader for only when it created the loader
534
+ * itself: each asset's requested level paths are released through `release("model", path)` once
535
+ * the last asset naming a path is gone, so a streamed package does not pin an internally owned
536
+ * cache. An explicitly supplied loader is the caller's — the world never releases its cache, so a
537
+ * second game or world holding the same model keeps it alive; the caller ends that lifetime with
538
+ * its own `release`. The loader is never cleared, and a path this world never asked for is never
539
+ * touched either way.
540
+ */
541
+ readonly assets?: IAssetLoader;
542
+ /** Game-owned terrain surface, handed straight to `TerrainTiles`. */
543
+ readonly surface: IWorldTilesOptions["surface"];
544
+ /** Passed straight to `TerrainTiles` for per-tile colliders. */
545
+ readonly createCollider?: IWorldTilesOptions["createCollider"];
546
+ /** Read once per update; an `Object3D` works. */
547
+ readonly follow: IWorldCellsFollow;
548
+ /** Chebyshev radius in cells to keep resident; a cell leaves only beyond `ring + 1`. */
549
+ readonly ring: number;
550
+ readonly budgets: IWorldCellsBudget;
551
+ readonly terrain?: IWorldCellsTerrainOptions;
552
+ /**
553
+ * How a scattered part whose own material is `transparent` is drawn. `"cutout"` (the default)
554
+ * gives the part a clone of that material with `transparent: false` and an `alphaTest`, so the
555
+ * instanced draw needs no per-instance sorting and the depth buffer rejects what is behind it;
556
+ * `"blend"` draws the material as authored. One clone per asset part, never per cell.
557
+ */
558
+ readonly transparentScatter?: "cutout" | "blend";
559
+ /**
560
+ * Shadows for the streamed world, all off by default. `cast` makes scattered batches cast into
561
+ * the scene's shadow map, but only their `castLevels` finest distance levels (default 1: the
562
+ * near shape), since a far LOD's shadow is sub-texel in any open-world shadow window and every
563
+ * caster is redrawn per shadow level. `receive` lets scatter and terrain receive shadows.
564
+ */
565
+ readonly shadows?: {
566
+ readonly cast?: boolean;
567
+ readonly castLevels?: number;
568
+ readonly receive?: boolean;
569
+ /**
570
+ * @deprecated Accepted and ignored. Clusters replace it: what a shadow level submits is bounded
571
+ * by the clusters its own window covers, not by a hand-tuned radius. It still validates, so a
572
+ * game that set it keeps compiling and keeps loading.
573
+ */
574
+ readonly castDistance?: number;
575
+ /**
576
+ * Called at most once a second after streamed records changed, so the shadow levels that read
577
+ * them redraw. `VirtualShadowNode.invalidateAll` is the whole of it, and a call with no region
578
+ * is exactly that. Not called per frame, and not called at all when nothing changed.
579
+ *
580
+ * When it is called about records, it is called with the union of the bounds of the records
581
+ * that changed — the placement position widened by the asset's own bounds, unioned over the
582
+ * records of this update and nothing else, so a hook that forwards it to
583
+ * `VirtualShadowNode.invalidateRegion` redraws the levels whose window covers them and leaves
584
+ * the rest of the cascade holding its maps. A call with no region is still a blanket one: the
585
+ * prewarm passes it while a caster's first draw is owed, and there is no region for that.
586
+ */
587
+ readonly invalidate?: (region?: IShadowRegion) => void;
588
+ /**
589
+ * An asset whose authored bounds are shorter than this casts into the finest shadow level only,
590
+ * default 1.5 m. Ground cover below it — ferns, grass, bushes — casts a shadow that is sub-texel
591
+ * noise at every range a wide level covers, and costs one draw per key per level there. Raise it
592
+ * to draw everything at every range, lower it to cull more. Measured on the authored bounds, so
593
+ * a package that scales a fern up four times is judged on the un-scaled fern.
594
+ */
595
+ readonly smallCasterMetres?: number;
596
+ };
597
+ /**
598
+ * Triangles a hand-placed chunk's merged material group may reach before one of its
599
+ * `InstancedMesh` is left instanced. Defaults to 43,690, the same 4 MiB
600
+ * budget the merged buffers are split on counted in the worst case, a de-indexed triangle: a merged
601
+ * group is a single draw, so expanding into it wins until the merged geometry is too big for the
602
+ * frame, and above it the instanced draw is the cheaper of the two. A group that crosses the budget
603
+ * is split into several meshes in traversal order rather than grown, and an instanced shape over
604
+ * 2,048 triangles is never expanded. Everything else in a chunk merges regardless — one mesh per
605
+ * material, in the main pass and in every shadow pass that redraws it.
606
+ */
607
+ readonly chunkMergeMaxTriangles?: number;
608
+ /**
609
+ * `(url) => Promise<Object3D>`, overriding `assets.model`; a raw `GLTFLoader` or a game's own
610
+ * loader works. The url is the authored one, so a compiled package wants `assets` instead.
611
+ */
612
+ readonly loadModel?: (url: string) => Promise<Object3D>;
613
+ /**
614
+ * Model loads in flight at once, assets and chunks together. Defaults to `loadAll`'s
615
+ * `concurrency`, six.
616
+ */
617
+ readonly concurrency?: number;
618
+ /**
619
+ * Cell-asset batches refiltered per `update`, nearest cell first. Defaults to 16. A cell that did
620
+ * not get its turn keeps drawing the batches it has until a later update replaces them.
621
+ */
622
+ readonly rebuildsPerUpdate?: number;
623
+ /**
624
+ * Seconds of motion to stream ahead of, default 1.5: the ring centres on where the follow point
625
+ * will be (`position + velocity * prefetchSeconds`), so a fast camera finds cells already loaded
626
+ * instead of outrunning them. The lead is capped at three quarters of the ring, so the cell the
627
+ * follow point is in always stays resident. `0` streams around the follow point itself.
628
+ */
629
+ readonly prefetchSeconds?: number;
630
+ /**
631
+ * New batch meshes created per update, default 2. On WebGPU each new InstancedMesh builds its
632
+ * own shader the first frame it draws (~10-20 ms of main thread), so a burst of first-seen assets
633
+ * is spread over frames instead of stacked into one; recycled meshes are not counted.
634
+ */
635
+ readonly freshMeshesPerUpdate?: number;
636
+ /**
637
+ * Side of the world-grid square one shared batch's records are clustered into, in world units.
638
+ * Defaults to the package's own `cellSize`, which is the square the placements are already cut
639
+ * on. Each `(key, cluster)` is its own caster InstancedMesh with its own bounds on the shadow
640
+ * caster layer, so a virtual-shadow level submits only the clusters its window covers. The main
641
+ * pass keeps one mesh per `asset:level:part` whatever this is, so it costs main-pass draws
642
+ * nothing. A larger square means fewer caster meshes and more of the world in every level render.
643
+ */
644
+ readonly clusterSize?: number;
645
+ /**
646
+ * Milliseconds one `update` may spend admitting streamed content — cell batches, terrain tiles,
647
+ * colliders and `maxDistance`/`lods` refilters together. Defaults to 2, and `Infinity` opts out
648
+ * of the ceiling entirely.
649
+ *
650
+ * The ceiling is a budget, not a promise: one unit of work always finishes, so a frame spends at
651
+ * most this plus its last unit. Work that does not fit waits for the next `update` rather than
652
+ * being dropped, and `stats().admission` reports what is waiting. A frame that admits nothing
653
+ * costs nothing here.
654
+ */
655
+ readonly admissionBudgetMs?: number;
656
+ /**
657
+ * Milliseconds source for the admission budget, `performance.now` by default. Injectable so a
658
+ * test can prove the ceiling instead of hoping a machine is slow enough to show it.
659
+ */
660
+ readonly admissionNow?: () => number;
661
+ /**
662
+ * The screen-space projection the extra levels of a baked AutoLOD chain are switched at, for an
663
+ * asset whose `world.json` entry names no `lods` of its own.
664
+ *
665
+ * A chain measures its levels' geometric error in world units and the model runtime turns that
666
+ * into pixels per frame; a batched draw cannot, so the same test is solved for distance once,
667
+ * here, and every placement beyond it draws the next level. `fovY` is the vertical field of view
668
+ * in degrees and `viewportHeight` the drawing-buffer height in raster pixels — a CSS height is the
669
+ * wrong number, exactly as it is for the model runtime.
670
+ *
671
+ * Defaults are 60° over 1080 raster rows: a desktop view, and what the switch distance is a
672
+ * function of. A world whose camera this class cannot see should pass its own, or a world with a
673
+ * narrow field of view (a telephoto gun sight) or a tall window will switch earlier than its
674
+ * pixels ask for. An asset with authored `lods` ignores all three numbers entirely: the package is
675
+ * the authority on its own shape.
676
+ *
677
+ * `maxPixelError` is the error budget, in pixels, every synthesized level is selected against, and
678
+ * it defaults to 4 rather than to the budget the asset's chain was registered with. The
679
+ * registered budget is calibrated for a `ModelLod`, which picks a level for *one* mesh from where
680
+ * that mesh is on screen; an instanced draw cannot, because every placement in the draw is at its
681
+ * own distance and the draw is one call with one geometry. The batched answer has to be solved
682
+ * once, for the whole key, which moves every switch distance outward by the ratio of the budgets —
683
+ * and a 1 px budget is calibrated for a hero object filling the screen. At 1 px a tree's switches
684
+ * land at 400–600 m on a 1080-row view, which on a 2 km map with a 640 m ring is past most of the
685
+ * resident world: the levels are all there and none of them is ever selected, which is the same
686
+ * full triangle bill the chain was added to remove. Four pixels puts the first switch inside the
687
+ * ring, where it can actually do something.
688
+ */
689
+ readonly autoLod?: {
690
+ readonly fovY?: number;
691
+ readonly viewportHeight?: number;
692
+ readonly maxPixelError?: number;
693
+ };
694
+ /**
695
+ * The GPU-driven main pass: one compute dispatch culls and LOD-selects every resident placement
696
+ * into a shared matrix buffer, and each main key draws its own region of it through an indirect
697
+ * record. The CPU keeps only the coarse per-cell visibility, so a walking camera costs no repack
698
+ * and no `maxDistance`/`lods` refilter.
699
+ *
700
+ * `true` by default where the backend can run it: it needs compute, storage buffers and
701
+ * `drawIndexedIndirect`, and a backend without them falls back to exactly this class's CPU path —
702
+ * a lost saving, never a wrong picture. `gpuScene: false`, `TN_GPU_SCENE=0` or `?tnGpuScene=0`
703
+ * turns it off, and `stats().gpuScene` and the `TN_WORLD_GPU_SCENE` line say which path a run
704
+ * took.
705
+ */
706
+ readonly gpuScene?: boolean;
707
+ /**
708
+ * Hold every dispatch's indirect args against the pure `cullAndSelect` reference and print the keys
709
+ * that disagree. `false` by default, and `TN_GPU_SCENE_VALIDATE=1` or `?tnGpuSceneValidate=1` turns
710
+ * it on.
711
+ *
712
+ * A readback is a queue submission and a mapped buffer, so this is a mode for answering "which key
713
+ * draws fewer instances than the CPU path" and never a walk to be measured with.
714
+ */
715
+ readonly gpuSceneValidate?: boolean;
716
+ /**
717
+ * Sample what the GPU actually selects in the main pass — a readback of the indirect args every
718
+ * 30 dispatches — and report it as `stats().gpuScene` and on the `TN_WORLD_GPU_SCENE`
719
+ * line. Off unless asked: the engine asks when the frame budget is on, and a validation turns it on
720
+ * by itself, so `TN_FRAME_BUDGET`'s `mainGpuTriangles` is the GPU-selected count and not the mesh
721
+ * capacity `tri=` reports. Set it explicitly when driving the world without a frame budget.
722
+ */
723
+ readonly gpuSceneTally?: boolean;
724
+ /**
725
+ * Record every GPU-dressed main batch mesh into one `BundleGroup` and replay the bundle instead of
726
+ * re-walking three's per-object path for each draw. Off by default: measured on machinefall's
727
+ * map-walk, bundles gave no CPU p50/p95 gain (the main thread is mostly idle and the frame is
728
+ * GPU/present bound), and `?tnBundles=0` stays the off path while `bundles: true` or
729
+ * `?tnBundles=1`/`TN_BUNDLES=1` turns it on. `stats().bundle` and the `TN_WORLD_BUNDLE` line say
730
+ * which way a run took.
731
+ *
732
+ * The render list inside a bundle is fixed when it is recorded, so a bundled mesh is never hidden:
733
+ * the dispatch draws zero instances for a key the camera cannot see, and an indirect draw of zero
734
+ * instances costs the GPU nothing. Toggling `visible` would force a re-record instead, and the
735
+ * shadow node's texel gate skips a bundled mesh for the same reason.
736
+ */
737
+ readonly bundles?: boolean;
738
+ /**
739
+ * Raise the LOD distance bias when the main pass is over its share of the frame's GPU budget, and
740
+ * decay it back toward 1 when it is comfortably under, default true.
741
+ *
742
+ * The main pass is triangle-bound: a flyover's near and mid tree levels are millions of triangles
743
+ * and the GPU time tracks that count. When `renderer.gpuMainMs()` — the smoothed main-pass GPU
744
+ * time — exceeds `mainGpuShare` of a 60 fps frame, every LOD switch is crossed earlier by
745
+ * multiplying the camera distance both selection paths compare by one shared multiplier, coarsening
746
+ * the same placements the GPU is already struggling to draw. It rises in bounded steps, never in a
747
+ * jump, so no level pops. `false` pins the bias at 1 for the whole run, which is byte-identical to
748
+ * the selection before this feature existed: `?tnAdaptiveLod=0`, `TN_ADAPTIVE_LOD=0` or
749
+ * `__tnAdaptiveLod = 0` also turns it off.
750
+ */
751
+ readonly adaptiveLod?: boolean;
752
+ /**
753
+ * The fraction of a frame's GPU time the main pass may take before the adaptive LOD bias rises,
754
+ * `(0, 1]`, default 0.5.
755
+ *
756
+ * The budget is `mainGpuShare x min(deltaTime, 1/60 s)`: the frame cap keeps the budget honest the
757
+ * same way the shadow refresh gate does, because the frame that draws an expensive main pass is
758
+ * itself long and reading its own inflated delta would let the cost it judges pass its own test.
759
+ * `?tnMainGpuShare=0.1`, `TN_MAIN_GPU_SHARE=0.1` or `__tnMainGpuShare = 0.1` forces adaptation on a
760
+ * GPU that would otherwise never exceed the share.
761
+ */
762
+ readonly mainGpuShare?: number;
763
+ /**
764
+ * Bake a whole-asset octahedral impostor for every alpha-cutout foliage asset and append it as the
765
+ * asset's terminal, two-triangle LOD, default false.
766
+ *
767
+ * Opt-in until the impostor casts a forest's shadow: it replaces the coarsest level the wide
768
+ * shadow levels draw, and its two-triangle card leaves the road and forest floor almost unshaded
769
+ * (PR #375 visual regression), so a default world keeps its source levels as shadow casters.
770
+ *
771
+ * The bake runs inside the render cadence — one of its sixteen views per `process` — so it costs a
772
+ * slice of a frame rather than a hitch, and it starts only after the asset's own model has been
773
+ * adopted. A seamless switch needs the impostor's atlas to cover the same cutout the source draws,
774
+ * so an asset whose levels carry no alpha-cutout part is never baked. `false` drops only the atlas
775
+ * terminal: every level the authored package and its baked chain give is kept, and the authored
776
+ * middle-level cutout coverage a level would otherwise be missing its foliage at is still applied.
777
+ */
778
+ readonly impostors?: boolean;
779
+ }
780
+ /**
781
+ * What the GPU actually selected in the main pass, from the scene's last landed tally.
782
+ *
783
+ * `triangles` is the GPU-selected count (`instanceCount x indexCount / 3` summed over the indirect
784
+ * records), never the mesh-capacity upper bound `renderer.info.render.triangles` reports, and it
785
+ * covers the main pass only: shadow passes are not tallied. `ageFrames` is how many dispatches have
786
+ * passed since those bytes were issued, so a caller never mistakes a stale sample for this frame's.
787
+ */
788
+ interface IWorldCellsGpuTally {
789
+ readonly instances: number;
790
+ readonly triangles: number;
791
+ readonly ageFrames: number;
792
+ }
793
+ interface IWorldCellsStats {
794
+ readonly residentCells: number;
795
+ readonly residentKeys: readonly string[];
796
+ /** Placement instances the resident cells hold, before any `maxDistance` filter. */
797
+ readonly instances: number;
798
+ readonly loadsInFlight: number;
799
+ /** Model loads waiting for a free lane; served nearest-cell first, in admission order. */
800
+ readonly loadsQueued: number;
801
+ /**
802
+ * Shared batches still to be minted, plus the ones minted and not yet drawn; `0` once
803
+ * `prewarmed` has resolved.
804
+ */
805
+ readonly pendingPrewarm: number;
806
+ /** Shared batches minted by the prewarm so far, cumulative. */
807
+ readonly prewarmMinted: number;
808
+ readonly evictions: number;
809
+ /** Cumulative `maxDistance`/`lods` refilters performed, across every update. */
810
+ readonly rebuilds: number;
811
+ /**
812
+ * Cumulative refilter *passes* run, whether or not they queued a rebuild. The pass walks every
813
+ * resident cell, derives both distance brackets and sorts what it found, so this is what says
814
+ * whether a follow point standing inside the 2 m step paid for it; `rebuilds` only says whether it
815
+ * was worth anything. See `LEVEL_REFILTER_STEP_METRES`.
816
+ */
817
+ readonly refilters: number;
818
+ /**
819
+ * Cumulative stale cell-assets the refilter passes found, across every update. The pass walks every
820
+ * resident cell and gates it as a whole before it looks at one batch, so a follow point that has
821
+ * crossed nothing leaves this still — which is what says the walk cost brackets and comparisons and
822
+ * not allocations.
823
+ */
824
+ readonly refilterEntries: number;
825
+ /**
826
+ * Cumulative refilters whose rebuild held the same records and were settled without a write,
827
+ * clear or compaction. A rising share of `rebuilds` is a follow point crossing a cell's distance
828
+ * bracket rather than any placement crossing a gate.
829
+ */
830
+ readonly unchanged: number;
831
+ readonly failures: number;
832
+ /**
833
+ * What the main-pass cull cost, cumulative, and the evidence that a settled camera costs nothing.
834
+ *
835
+ * `frustums` is one a frame that had a perspective camera — it used to be one per main mesh, ~230
836
+ * of them on a 2 km walk. `windows` is how many of those meshes had to re-derive their window at
837
+ * all, and `repacks` how many copied records: both are 0 on a frame where the visible squares did
838
+ * not change and no record joined or left, which is what the two integer comparisons in
839
+ * `SharedBatch.cullFrom` buy.
840
+ */
841
+ readonly mainCull: {
842
+ readonly frustums: number;
843
+ readonly windows: number;
844
+ readonly repacks: number;
845
+ readonly visibleEpoch: number;
846
+ };
847
+ /**
848
+ * What the GPU-driven main pass is doing, and why it is not: `on` is the answer, `reason` is the
849
+ * one line `TN_WORLD_GPU_SCENE` printed, and `dispatches` is what the walk actually paid for its
850
+ * per-instance work. `mainCull.repacks` and `refilters` stay at `0` while it is on — that is the
851
+ * CPU work it replaced, counted by the same counters that report it when it is off. `dressed` over
852
+ * `meshes` is the marker line's own pair, and it is the one that says the scene has meshes to draw
853
+ * with: an `on` with `0` dressed is a ring the CPU path is still drawing.
854
+ */
855
+ readonly gpuScene: {
856
+ readonly on: boolean;
857
+ readonly reason: string;
858
+ readonly dispatches: number;
859
+ /** Resident source records: one per placement, shared by every part of the level it reached. */
860
+ readonly instances: number;
861
+ readonly keys: number;
862
+ readonly dressed: number;
863
+ readonly meshes: number;
864
+ /**
865
+ * Instances and triangles the GPU actually selected in the main pass, and the age of that
866
+ * sample in dispatches. Absent until the first tally lands, never zero: the scene's own
867
+ * `instances` above is what it holds, and `gpuInstances` is what the GPU drew.
868
+ */
869
+ readonly gpuInstances?: number;
870
+ readonly gpuTriangles?: number;
871
+ readonly gpuTallyAgeFrames?: number;
872
+ };
873
+ /**
874
+ * What the main pass's draw bundles are doing: `children` is how many GPU-dressed meshes are
875
+ * parented under the one `BundleGroup`, and `records` is how many times that group has been
876
+ * re-recorded since the world loaded.
877
+ *
878
+ * A record is a structural change and nothing else — a key minted or retired, a geometry or
879
+ * material swapped, or a GPU-scene buffer regrow that re-dresses its meshes. Streaming, culling and
880
+ * LOD do not move it, which is the whole claim: a 200-frame walk holds `records` at the number of
881
+ * keys that came and went, and a settled camera holds it still.
882
+ */
883
+ readonly bundle: {
884
+ readonly on: boolean;
885
+ readonly children: number;
886
+ readonly records: number;
887
+ };
888
+ /**
889
+ * The runtime impostor path: how many assets appended a terminal level, how many bakes are still
890
+ * queued or in flight, the triangles all terminal levels submit together — the number the whole
891
+ * feature exists to reduce — and the cache size: the logical colour atlas bytes held (each atlas's
892
+ * RGBA8 colour and normal mip chains), not every physical GPU buffer a render target may own, and
893
+ * the budget those bytes are enforced against.
894
+ */
895
+ readonly impostor: {
896
+ readonly assets: number;
897
+ readonly pending: number;
898
+ readonly atlasBytes: number;
899
+ readonly budgetBytes: number;
900
+ readonly terminalTriangles: number;
901
+ /**
902
+ * The whole-map far aggregates: one `InstancedMesh` per atlas cache key, the ORIGINAL placements
903
+ * they hold (`instances`), how many of those the near ring has taken back (`nearOwned`), the
904
+ * physical instance bytes, and how many times an aggregate's buffer has been written. Missing
905
+ * measurements stay absent; a settled camera holds `uploads` still.
906
+ */
907
+ readonly far: {
908
+ readonly aggregates: number;
909
+ readonly instances: number;
910
+ readonly live: number;
911
+ readonly nearOwned: number;
912
+ readonly bytes: number;
913
+ readonly uploads: number;
914
+ };
915
+ };
916
+ /** Cumulative rejected requests: cells skipped, instances or bytes refused, terrain retries. */
917
+ readonly pressure: {
918
+ readonly cells: number;
919
+ readonly instances: number;
920
+ readonly bytes: number;
921
+ };
922
+ /**
923
+ * What the last `update` spent on admitting streamed content, and what it left behind.
924
+ *
925
+ * A rising `backlog` is the honest signal that admission is not keeping up with the follow point;
926
+ * it is the one number that says a player is outrunning the world.
927
+ */
928
+ readonly admission: {
929
+ /** Milliseconds the last `update` spent admitting; at most the budget plus its last unit. */
930
+ readonly spentMs: number;
931
+ /** Cell-asset builds the last `update` had no budget for, still queued for a later one. */
932
+ readonly deferred: number;
933
+ /** Units of work those builds still owe — placement slices left plus meshes left to publish. */
934
+ readonly backlog: number;
935
+ };
936
+ }
937
+ /**
938
+ * A plain `{ min, max }` region, the shape a shadow level's invalidation reads and the shape a game
939
+ * can be handed without importing three. Plain numbers, so the object a caller records its bounds
940
+ * in is the object that crosses the boundary.
941
+ */
942
+ interface IShadowRegion {
943
+ min: {
944
+ x: number;
945
+ y: number;
946
+ z: number;
947
+ };
948
+ max: {
949
+ x: number;
950
+ y: number;
951
+ z: number;
952
+ };
953
+ }
954
+ /**
955
+ * Stream a Blender-authored world package by cell and keep it resident around a followed point.
956
+ *
957
+ * The class composes `TerrainTiles` for the package's heightmap, builds one `InstancedBatch` per
958
+ * resident cell asset run, distance level and mesh part, and loads hand-placed chunk GLBs through
959
+ * `loadAll` + `addInSlices`. Ring residency, per-asset `maxDistance` filtering, the per-asset `lods`
960
+ * levels, hard budgets and generation-tokened cancellation all live here; every geometry, material
961
+ * and surface still comes from the package's GLBs and the game.
962
+ *
963
+ * An asset is drawn per part, not per model: a GLB with several primitives is one `InstancedBatch`
964
+ * each, and a scattered part whose own material is transparent draws as an alpha cutout unless the
965
+ * game asks for blending, because an `InstancedMesh` cannot sort its instances.
966
+ *
967
+ * An asset whose package entry names no `lods` is drawn at the levels its own model carries a baked
968
+ * AutoLOD chain for: the levels an instanced draw cannot reach by itself, switched at the distance
969
+ * their error projects over the `autoLod` viewport. Authored `lods` win; a chain is only a fallback.
970
+ *
971
+ * @situation stream a large Blender-authored world by cell instead of one huge GLB
972
+ * @situation keep scattered props and hand-placed chunks resident around a moving player
973
+ * @situation honour per-asset draw distances and hard streaming budgets without a mid-frame throw
974
+ * @constraint surface is the game's; this class creates no material, colour or geometry
975
+ * @constraint budgets are hard caps that report pressure instead of over-committing
976
+ * @constraint model loads are bounded by `concurrency` (default 12) across every resident cell, not per cell
977
+ * @constraint refilters are bounded by `rebuildsPerUpdate` (default 16) per update, nearest cell first
978
+ * @constraint admission is bounded by `admissionBudgetMs` (default 2) per update across every path, plus at most one unit each for terrain and props; while props are queued terrain takes at most half, so neither starves the other, and a deferred cell keeps drawing what it has
979
+ * @constraint SkinnedMesh parts are skipped; an instanced copy would draw one rest pose
980
+ * @constraint a baked chain's switch distances are measured against `autoLod` (default 4 px of error over 60° and 1080 raster rows), because an instanced draw cannot select a level per instance; an asset with authored `lods` never consults it
981
+ * @override ring, budgets, terrain tile size/resolution, terrain stream and collider radius, `transparentScatter`, `clusterSize` and `shadows.invalidate`, load `concurrency`, `rebuildsPerUpdate`, `admissionBudgetMs` and the package's per-asset maxDistance
982
+ * @constraint `prewarmed` resolves once every prewarmed shared batch has been drawn; a game with a loading screen waits on it, and `stats().pendingPrewarm` is the same gate as a number
983
+ * @constraint every `asset:level:part` is one InstancedMesh for the main pass, plus one caster InstancedMesh per world-grid square of `clusterSize` on the shadow caster layer, so the main pass draws one mesh per key and a shadow level submits only the squares it covers
984
+ * @constraint two definitions the asset loader resolves to one model — the same cooked `glb`, the same `lods` at the same distances, the same `maxDistance` and bounds — are one asset under the lexicographically smallest id: one model load, one set of `asset:level:part` keys, one prewarm and one refcount, released when the last cell holding any member of the group leaves the ring; `TN_WORLD_ASSET_ALIAS` reports how many of the package's assets are really distinct
985
+ * @constraint the main pass mesh draws only the squares the render camera's frustum covers — on by default, narrowed once per frame for every main batch by the engine's render-cadence dispatch, never for an orthographic camera — a batch with nothing to draw is hidden rather than submitted at `count 0`, and `TN_WORLD_MAIN_CULL` reports both every five seconds
986
+ * @constraint a loaded chunk is merged by material before it is added, so it submits one draw per material rather than one per node; a skinned, multi-material or morph-target mesh, one carrying a baked AutoLOD chain, and an instanced mesh past `chunkMergeMaxTriangles` (default 43,690 triangles) or with a shape over 2,048 triangles, all keep their own geometry; a material group crossing 131,072 vertices (4 MiB of position + normal + uv) is split into several meshes in traversal order instead of one giant upload, indexed parts keep their index, and `TN_WORLD_CHUNK_MERGE` reports what the merge did and the bytes it left
987
+ * @constraint `shadows.castDistance` is accepted and ignored (clusters replaced it); `shadows.invalidate` is called at most once a second after streamed records changed
988
+ * @example
989
+ * const world = await WorldCells.load({ url: "/world/world.json", surface, follow, ring: 1, budgets: { residentCells: 25, instances: 20000, bytes: 8000000 } });
990
+ * scene.add(world);
991
+ * world.update();
992
+ */
993
+ declare class WorldCells extends Group implements IComputeDriven {
994
+ #private;
995
+ readonly processCadence: "render";
996
+ private constructor();
997
+ static load(options: IWorldCellsLoadOptions): Promise<WorldCells>;
998
+ get released(): boolean;
999
+ get warmupNodes(): readonly unknown[];
1000
+ /**
1001
+ * Resolves once every minted batch has been drawn at least once, main and shadow context.
1002
+ *
1003
+ * The loading gate a game with a loading screen waits on, so the shader builds the prewarm exists
1004
+ * to move happen behind it instead of in the first seconds of play. It counts updates the world
1005
+ * was in a scene for, because a batch added while the world is not in a scene is projected by
1006
+ * nothing and builds nothing. `dispose` settles it too: a world torn down mid-load must not leave
1007
+ * a game awaiting it forever.
1008
+ */
1009
+ get prewarmed(): Promise<void>;
1010
+ /**
1011
+ * Per-frame residency step; call it wherever `TerrainTiles.process` is called.
1012
+ *
1013
+ * Reads the follow target, keeps the in-ring cells, evicts cells beyond the hysteresis ring, and
1014
+ * spends one admission budget on everything the ring newly wants: cell-asset batches, terrain
1015
+ * tiles, colliders and the `maxDistance`/`lods` refilters the follow point has moved far enough to
1016
+ * have changed. Whatever does not fit waits for the next call — nothing is dropped, and
1017
+ * `stats().admission` is the honest report of what is waiting. A terrain budget throw is caught
1018
+ * and counted, and so is a teardown that throws while releasing what left; `failures` carries
1019
+ * both. Every other error, the game's included, escapes.
1020
+ *
1021
+ * The residency pass itself is skipped while the follow point has moved less than half a metre
1022
+ * since the last one and nothing is pending (a load, a cell build, a deferred terrain admission, a
1023
+ * prewarm mint or a shadow level not yet told), because the pass would re-derive the set the
1024
+ * world already holds. The drain, the prewarm, the invalidation and the main cull below it run
1025
+ * every time.
1026
+ *
1027
+ * `camera` is the frame's render camera, which the engine's render-cadence dispatch hands over
1028
+ * because it is the one this world draws with. A game that drives this world itself can pass it,
1029
+ * and a game that does not keeps every resident record drawn.
1030
+ */
1031
+ update(renderer?: IRendererLike, camera?: Camera): void;
1032
+ /**
1033
+ * The render-cadence dispatch. The engine hands the frame's render camera over, and this world's
1034
+ * main windows follow it; see {@link #cullMainPass} for why the decision is not the meshes' own.
1035
+ */
1036
+ process(renderer?: IRendererLike, camera?: Camera): void;
1037
+ attachRenderer(renderer: IRendererLike): void;
1038
+ /** Current per-asset reference count; an asset absent from the map is `0`. */
1039
+ assetRefCounts(): Readonly<Record<string, number>>;
1040
+ /**
1041
+ * Turn on the GPU-selected tally for the rest of the world's life.
1042
+ *
1043
+ * The engine calls this when the frame budget is on, and a `gpuSceneValidate` turns it on without
1044
+ * it. Set `gpuSceneTally: true` at load instead when a world is driven outside a frame budget.
1045
+ */
1046
+ enableGpuSceneTally(): void;
1047
+ /**
1048
+ * The GPU's own main-pass selection from the last landed tally, or `undefined` before one lands.
1049
+ *
1050
+ * `triangles` is the GPU-selected count, which `renderer.info.render.triangles` cannot give for an
1051
+ * indirect draw: three counts the mesh capacity there, an upper bound over what the kernel could
1052
+ * select. This is what the kernel selected. Main pass only, and `ageFrames` is its staleness.
1053
+ */
1054
+ gpuSceneTally(): IWorldCellsGpuTally | undefined;
1055
+ stats(): IWorldCellsStats;
1056
+ detach(): void;
1057
+ dispose(): void;
1058
+ }
1059
+
174
1060
  interface IHeightfieldOrigin {
175
1061
  readonly x: number;
176
1062
  readonly z: number;
@@ -251,4 +1137,4 @@ declare class Heightfield extends Group implements IComputeDriven {
251
1137
  toGeometry(): BufferGeometry;
252
1138
  }
253
1139
 
254
- export { Heightfield, type IHeightfieldOptions, type IHeightfieldOrigin, type IHeightfieldSamplerOptions, type IHeightfieldWorldPassOptions, type IWorldCapabilities, type IWorldTileColliderInput, type IWorldTilesTopologyObservation, TerrainTiles, getWorldCapabilities };
1140
+ export { Heightfield, type IAdmissionBudget, type IHeightfieldOptions, type IHeightfieldOrigin, type IHeightfieldSamplerOptions, type IHeightfieldWorldPassOptions, type ILoadTerrainSplatOptions, type IShadowRegion, type ITerrainSplatLayer, type ITerrainSplatMaskedLayer, type ITerrainSplatTable, type IWorldAsset, type IWorldAssetBounds, type IWorldAssetLod, type IWorldCapabilities, type IWorldCell, type IWorldCellsBudget, type IWorldCellsFollow, type IWorldCellsLoadOptions, type IWorldCellsStats, type IWorldCellsTerrainOptions, type IWorldExtent, type IWorldPackage, type IWorldPackageError, type IWorldPackageValidationOptions, type IWorldRun, type IWorldTerrain, type IWorldTileColliderInput, type IWorldTilesTopologyObservation, TERRAIN_VALIDATE_FLAG, TERRAIN_VALIDATE_MARKER, TerrainTiles, WorldCells, type WorldPackageErrorCode, cellPlacements, getWorldCapabilities, heightSamplerFromHeightmap, loadTerrainSplat, loadWorldHeightmap, terrainValidationRequested, validateWorldPackage };