@navaramap/three 0.0.1 → 0.0.2

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 (54) hide show
  1. package/README.md +70 -0
  2. package/dist/assets/fontWorker-8oWvyvwL.js +2 -0
  3. package/dist/assets/fontWorker-8oWvyvwL.js.map +1 -0
  4. package/dist/assets/index-D9G7kIEj.js +25 -0
  5. package/dist/assets/index-D9G7kIEj.js.map +1 -0
  6. package/dist/assets/{navara_wasm_font_worker_bg-Bnck8OGf.wasm → navara_wasm_font_worker_bg-CkZP9l2i.wasm} +0 -0
  7. package/dist/assets/{navara_wasm_worker_bg-DG9ORAmj.wasm → navara_wasm_worker_bg-Q8zrkLCR.wasm} +0 -0
  8. package/dist/draco_decoder.wasm +0 -0
  9. package/dist/draco_decoder2.wasm +0 -0
  10. package/dist/index.d.ts +445 -16
  11. package/dist/index.js +6151 -5717
  12. package/dist/index.js.map +1 -1
  13. package/dist/index.umd.cjs +241 -222
  14. package/dist/index.umd.cjs.map +1 -1
  15. package/package.json +34 -33
  16. package/src/core/EffectDesc.ts +2 -4
  17. package/src/core/MeshDesc.ts +1 -2
  18. package/src/core/MeshDescWithSelectiveEffect.ts +1 -2
  19. package/src/declutter/DeclutterManager.test.ts +235 -0
  20. package/src/declutter/DeclutterManager.ts +256 -0
  21. package/src/declutter/index.ts +7 -0
  22. package/src/declutter/kernel.ts +46 -0
  23. package/src/declutter/types.ts +61 -0
  24. package/src/declutter/wasmKernel.ts +13 -0
  25. package/src/device.ts +18 -4
  26. package/src/evaluations/FeatureEvaluator.ts +21 -0
  27. package/src/event/context.ts +12 -0
  28. package/src/event/features/modelGpuBytes.ts +1 -3
  29. package/src/event/hillshade.test.ts +7 -13
  30. package/src/event/index.ts +2 -3
  31. package/src/event/tileHandler.ts +54 -0
  32. package/src/index.ts +137 -35
  33. package/src/material/enhancer/ShaderReplacer.ts +1 -2
  34. package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/mutates.ts +6 -0
  35. package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/types.ts +11 -0
  36. package/src/material/enhancer/tileComposite/tileCompositeBaseEnhancer/mutates.test.ts +83 -0
  37. package/src/material/enhancer/tileComposite/tileCompositeBaseEnhancer/mutates.ts +13 -3
  38. package/src/mesh/batchTexture.ts +2 -4
  39. package/src/mesh/batchedSdfText.ts +61 -1
  40. package/src/mesh/sdfText.ts +170 -5
  41. package/src/mesh/sprite/instancedSprite.ts +245 -3
  42. package/src/orchestrators/RenderPassOrchestrator.ts +25 -0
  43. package/src/passes/CustomRenderPass.ts +21 -5
  44. package/src/passes/RenderTargetCopyPass.ts +24 -2
  45. package/src/test-utils/engine.ts +53 -0
  46. package/src/tileTexture/TileTextureCompositor.test.ts +2 -0
  47. package/src/tileTexture/TileTextureCompositor.ts +7 -1
  48. package/src/utils/fixedGpuFootprint.test.ts +110 -0
  49. package/src/utils/fixedGpuFootprint.ts +162 -0
  50. package/src/worker/tasks/toCreasedNormals.ts +2 -7
  51. package/dist/assets/fontWorker-CCWL5pbx.js +0 -2
  52. package/dist/assets/fontWorker-CCWL5pbx.js.map +0 -1
  53. package/dist/assets/index-BdtHOOT9.js +0 -12
  54. package/dist/assets/index-BdtHOOT9.js.map +0 -1
@@ -0,0 +1,61 @@
1
+ /**
2
+ * One label/sprite competing for screen space in a declutter pass.
3
+ *
4
+ * The local box is expressed around the projected anchor in the shader's
5
+ * billboard-local units — CSS pixels when `sizeInMeters` is false, meters
6
+ * otherwise — with +X right and +Y up (view-space convention). The manager
7
+ * converts it to a screen-pixel AABB using the same `nvr_pxToWorld` math the
8
+ * vertex shaders use, so the collision box matches what is actually drawn.
9
+ */
10
+ export type DeclutterCandidate = {
11
+ /** World-space anchor in ECEF meters (f64), before the height offset. */
12
+ anchorX: number;
13
+ anchorY: number;
14
+ anchorZ: number;
15
+ /** Extra offset in meters along the spherical surface normal — CPU mirror
16
+ * of `mvr_getMvHeightOffset` (sprite_height_pars_vertex.glsl). */
17
+ addHeight: number;
18
+ /** Local box around the anchor (px or meters, see `sizeInMeters`). */
19
+ minX: number;
20
+ maxX: number;
21
+ minY: number;
22
+ maxY: number;
23
+ /** Unit of the local box, mirroring the material's `sizeInMeters`. */
24
+ sizeInMeters: boolean;
25
+ /** Placement priority: higher wins an overlap. */
26
+ priority: number;
27
+ /** Whether the label is currently shown (its fade target is "visible").
28
+ * Feeds placement hysteresis: shown labels win equal-priority ties and
29
+ * tolerate marginal overlaps, so slow camera drift doesn't make near-tied
30
+ * labels flicker in and out. */
31
+ isShown: boolean;
32
+ /** Participant that owns this candidate; receives the placement result. */
33
+ owner: DeclutterParticipant;
34
+ /** Participant-defined id (mesh index for text batches, instance index for
35
+ * sprites) passed back to `applyDeclutter`. */
36
+ handle: number;
37
+ };
38
+
39
+ /** Duration of the show/hide fade applied to placement results (ms). */
40
+ export const DECLUTTER_FADE_MS = 300;
41
+
42
+ /** A mesh that contributes label candidates to the shared declutter pass. */
43
+ export type DeclutterParticipant = {
44
+ /**
45
+ * Append the currently-visible, declutter-enabled candidates to `out`.
46
+ * Called once per placement pass; must not retain `out`.
47
+ */
48
+ collectDeclutterCandidates: (out: DeclutterCandidate[]) => void;
49
+ /**
50
+ * Apply a placement result as the label's fade *target*. Must be cheap and
51
+ * idempotent — the pass calls it for every candidate on every run, most
52
+ * often with an unchanged value.
53
+ */
54
+ applyDeclutter: (handle: number, hidden: boolean) => void;
55
+ /**
56
+ * Advance this participant's hide factors toward their targets by
57
+ * `deltaMs / DECLUTTER_FADE_MS`. Called by the manager every update while
58
+ * anything is fading; returns true while any label is still mid-fade.
59
+ */
60
+ stepDeclutterFade: (deltaMs: number) => boolean;
61
+ };
@@ -0,0 +1,13 @@
1
+ import { declutterPlace } from "@navaramap/engine-api";
2
+
3
+ import type { DeclutterKernel } from "./kernel";
4
+
5
+ /**
6
+ * @internal
7
+ * {@link DeclutterKernel} backed by the Rust `declutterPlace` in
8
+ * `navara_wasm_api`. The WASM module must be initialized (via `initNavaraApi()`)
9
+ * before the first placement pass runs; `ThreeView` awaits that during setup.
10
+ */
11
+ export const wasmDeclutterKernel: DeclutterKernel = {
12
+ place: declutterPlace,
13
+ };
package/src/device.ts CHANGED
@@ -50,6 +50,16 @@ export function isMobileDevice(): boolean {
50
50
  /** Maximum pixel ratio for mobile devices to balance quality vs performance */
51
51
  const MOBILE_MAX_PIXEL_RATIO = 1.0;
52
52
 
53
+ /**
54
+ * Default pixel-ratio cap applied on every mobile device (without opting into
55
+ * `mobileOptimization`). The fixed screen-sized render-target stack (composer
56
+ * buffers, gbuffer MRT, depth textures — ~100 B/px, counted by the ledger as
57
+ * `fixed_gpu_bytes`) grows quadratically with the drawing-buffer size: at
58
+ * DPR 3 it alone can exceed a mobile tile-cache budget and permanently close
59
+ * the load gate. Above DPR 2 the sharpness gain is marginal on phone screens.
60
+ */
61
+ const MOBILE_DEFAULT_MAX_PIXEL_RATIO = 2.0;
62
+
53
63
  export type DevicePixelRatioOptions = {
54
64
  /** User-specified pixel ratio override (takes precedence over all other settings) */
55
65
  override?: number;
@@ -389,7 +399,9 @@ export function getDefaultDynamicSse(): DynamicSseSettings {
389
399
 
390
400
  /**
391
401
  * Gets an appropriate pixel ratio for the current device.
392
- * Caps the ratio on mobile devices only when mobileOptimization is enabled.
402
+ * Mobile devices are capped at 2 by default (see
403
+ * {@link MOBILE_DEFAULT_MAX_PIXEL_RATIO}), and at 1 when mobileOptimization
404
+ * is enabled; an explicit `override` (the `pixelRatio` option) bypasses both.
393
405
  *
394
406
  * @param options - Configuration options for pixel ratio
395
407
  * @returns Pixel ratio appropriate for the device
@@ -405,9 +417,11 @@ export function getDevicePixelRatio(options?: DevicePixelRatioOptions): number {
405
417
 
406
418
  const deviceRatio = window.devicePixelRatio ?? 1;
407
419
 
408
- // Only cap pixel ratio on mobile when mobileOptimization is enabled
409
- if (options?.mobileOptimization && isMobileDevice()) {
410
- return Math.min(deviceRatio, MOBILE_MAX_PIXEL_RATIO);
420
+ if (isMobileDevice()) {
421
+ const cap = options?.mobileOptimization
422
+ ? MOBILE_MAX_PIXEL_RATIO
423
+ : MOBILE_DEFAULT_MAX_PIXEL_RATIO;
424
+ return Math.min(deviceRatio, cap);
411
425
  }
412
426
 
413
427
  return deviceRatio;
@@ -55,6 +55,9 @@ export type EvaluatableMaterialProperty = {
55
55
  size: AvailableMaterialProperty["size"];
56
56
  /** Opacity expression from layer configuration (for polygons/points/text where supported). */
57
57
  opacity: AvailableMaterialProperty["opacity"];
58
+ /** Per-feature declutter placement priority (for points/billboards/text);
59
+ * higher wins an overlap. Overrides the layer's `declutterPriority`. */
60
+ declutterPriority: AvailableMaterialProperty["declutterPriority"];
58
61
  /** Image URL for billboards; packed into a shared per-mesh texture atlas. */
59
62
  image: string;
60
63
  };
@@ -70,6 +73,7 @@ type EvaluatedMaterialProperty = {
70
73
  width: number;
71
74
  size: number;
72
75
  opacity: number;
76
+ declutterPriority: number;
73
77
  image: string | null;
74
78
  };
75
79
 
@@ -308,6 +312,8 @@ export class FeatureEvaluator {
308
312
  * - `width` - Line width in pixels (for polylines)
309
313
  * - `size` - Feature size in pixels (for points/text)
310
314
  * - `opacity` - Feature opacity 0-1
315
+ * - `declutterPriority` - Placement priority for decluttering; higher wins
316
+ * an overlap (for points/billboards/text with `declutter` enabled)
311
317
  * - `image` - Image URL (for billboards); loaded once per distinct URL and
312
318
  * packed into the layer's texture atlas. Return `null` to clear a
313
319
  * previous per-feature image and revert to the material's default `url`;
@@ -387,6 +393,12 @@ export class FeatureEvaluator {
387
393
  if (evaluated.opacity != null) {
388
394
  obj.setFeatureOpacityByBatchId(batchId, evaluated.opacity);
389
395
  }
396
+ if (evaluated.declutterPriority != null) {
397
+ obj.setFeatureDeclutterPriorityByBatchId(
398
+ batchId,
399
+ evaluated.declutterPriority,
400
+ );
401
+ }
390
402
  if ("image" in evaluated) {
391
403
  // Async by nature (the image may need fetching); the atlas dedupes
392
404
  // loads by URL so evaluating many features costs one fetch per image.
@@ -415,6 +427,15 @@ export class FeatureEvaluator {
415
427
  if (evaluated.opacity != null && obj instanceof BatchedSdfTextMesh) {
416
428
  obj.setFeatureOpacityByBatchIndex(batchIndex, evaluated.opacity);
417
429
  }
430
+ if (
431
+ evaluated.declutterPriority != null &&
432
+ obj instanceof BatchedSdfTextMesh
433
+ ) {
434
+ obj.setFeatureDeclutterPriorityByBatchIndex(
435
+ batchIndex,
436
+ evaluated.declutterPriority,
437
+ );
438
+ }
418
439
  return;
419
440
  }
420
441
 
@@ -22,6 +22,7 @@ import type { Texture } from "three";
22
22
  import type { ViewEvents } from "..";
23
23
  import type { ThreeViewCamera } from "../camera";
24
24
  import type { ViewContext } from "../core";
25
+ import type { DeclutterManager } from "../declutter";
25
26
  import type { LayersManager } from "../layersManager";
26
27
  import type { TileMesh } from "../mesh/tile";
27
28
  import type { Scenes, TexturizedSceneByTileCoordinates } from "../scene";
@@ -120,6 +121,12 @@ export type TileHandler = {
120
121
  textureZoom: number,
121
122
  textureWidth: number,
122
123
  ) => number;
124
+ /** Rust's clamped WebMercator northing (`navara_geometry::mercator_y`).
125
+ * The composite paste derives its per-slot reprojection constants through
126
+ * this so paste and bake (`uv_rect_from_extents_mercator`) share one
127
+ * implementation — a drifting reimplementation misaligns the pasted
128
+ * latitude band (and NaNs on the unclamped polar band). */
129
+ mercatorY: (lat: number) => number;
123
130
  };
124
131
 
125
132
  /**
@@ -214,6 +221,7 @@ type EventContextArgs = {
214
221
  textureFragmentIndex?: Map<string, Set<TextureSlot>>;
215
222
  tileMeshToFragmentIds?: Map<TileMesh, Set<string>>;
216
223
  hillshadeContext?: HillshadeContext;
224
+ declutter?: DeclutterManager;
217
225
  };
218
226
 
219
227
  /**
@@ -248,6 +256,9 @@ export class EventContext {
248
256
  readonly textureFragmentIndex?: Map<string, Set<TextureSlot>>;
249
257
  readonly tileMeshToFragmentIds?: Map<TileMesh, Set<string>>;
250
258
  readonly hillshadeContext?: HillshadeContext;
259
+ /** Shared screen-space label declutterer; text/sprite meshes register as
260
+ * participants on creation. Optional so headless contexts can skip it. */
261
+ readonly declutter?: DeclutterManager;
251
262
 
252
263
  updatedAt = 0;
253
264
 
@@ -280,5 +291,6 @@ export class EventContext {
280
291
  this.textureFragmentIndex = args.textureFragmentIndex;
281
292
  this.tileMeshToFragmentIds = args.tileMeshToFragmentIds;
282
293
  this.hillshadeContext = args.hillshadeContext;
294
+ this.declutter = args.declutter;
283
295
  }
284
296
  }
@@ -60,9 +60,7 @@ export function sumModelGpuBytes(obj: Object3D): number {
60
60
  }
61
61
  }
62
62
  const material = (node as Mesh | Points).material as
63
- | Material
64
- | Material[]
65
- | undefined;
63
+ Material | Material[] | undefined;
66
64
  const materials = Array.isArray(material)
67
65
  ? material
68
66
  : material
@@ -494,9 +494,8 @@ describe("hillshade normal map generation", () => {
494
494
  expect(loadedTexs.has(entityId)).toBe(true);
495
495
  const normalMap = loadedTexs.get(entityId);
496
496
  expect(normalMap).toBeInstanceOf(Texture);
497
- if (normalMap) {
498
- expect((normalMap.image as { width: number }).width).toBe(4); // Content size (no padding in normal map)
499
- }
497
+ // Content size (no padding in normal map)
498
+ expect((normalMap?.image as { width: number } | undefined)?.width).toBe(4);
500
499
 
501
500
  // Verify temp DEM entry was stored for edge updates
502
501
  const tempDem = hillshadeContext.getTempDem(entityId);
@@ -584,16 +583,11 @@ describe("hillshade normal map generation", () => {
584
583
  target_entity_gen: 123,
585
584
  } as HillshadeBackfilledEvent);
586
585
 
587
- if (direction < 3) {
588
- // Not all edges received yet
589
- expect(hillshadeContext.getTempDem(entityId)).toBeDefined();
590
- expect(hillshadeContext.getTempDem(entityId)?.receivedEdges.size).toBe(
591
- direction + 1,
592
- );
593
- } else {
594
- // All 4 edges received, temp DEM should be cleaned up
595
- expect(hillshadeContext.getTempDem(entityId)).toBeUndefined();
596
- }
586
+ // The temp DEM tracks received edges until all 4 arrive, then it is
587
+ // cleaned up (so `getTempDem` returns undefined on the last iteration).
588
+ expect(hillshadeContext.getTempDem(entityId)?.receivedEdges.size).toBe(
589
+ direction < 3 ? direction + 1 : undefined,
590
+ );
597
591
  }
598
592
 
599
593
  // Verify texture still exists after cleanup
@@ -23,7 +23,7 @@ import { BatchedSdfTextMesh, Layer } from "..";
23
23
  import { disposeTexture } from "../loaders";
24
24
  import { getImageDataFromBlob } from "../tasks/getImageDataFromBlob";
25
25
 
26
- import { EventContext } from "./context";
26
+ import { type EventContext } from "./context";
27
27
  import {
28
28
  checkFeatureParallel,
29
29
  processRenderableFeatureAdded,
@@ -463,12 +463,11 @@ async function processRequestedData(ctx: EventContext, req: DataRequestEvent) {
463
463
  return;
464
464
  }
465
465
 
466
- let u8a: Uint8Array | null = new Uint8Array(data);
466
+ const u8a = new Uint8Array(data);
467
467
  buf.setU8(req.handle, req.bits, u8a);
468
468
 
469
469
  // Prevent memory leak
470
470
  u8a.set([]);
471
- u8a = null;
472
471
 
473
472
  data.set([]);
474
473
  })
@@ -0,0 +1,54 @@
1
+ import { mercatorY, type Core } from "@navaramap/engine";
2
+
3
+ import type { TileHandler } from "./context";
4
+
5
+ export type TileHandlerDeps = {
6
+ /** Accessor (not a snapshot) — the Core is created asynchronously after
7
+ * WASM init and can be dropped on dispose. */
8
+ getCore: () => Core | undefined;
9
+ /** Per-frame cached revision snapshots (refreshed once per `_render`), so
10
+ * per-tile `onBeforeRender` gating costs no WASM-boundary call. */
11
+ getVectorRevision: () => number;
12
+ getRasterRevision: () => number;
13
+ };
14
+
15
+ /**
16
+ * Build the {@link TileHandler} context over a WASM `Core`. Extracted from
17
+ * `ThreeView` so tests can assemble the same handler over a shared test engine
18
+ * (`test-utils/engine.ts`) instead of stubbing the Rust-backed functions.
19
+ */
20
+ export function createTileHandler(deps: TileHandlerDeps): TileHandler {
21
+ const { getCore } = deps;
22
+ return {
23
+ getMartini: (id) => {
24
+ return getCore()?.getMartini(id);
25
+ },
26
+ getTile: (handle) => {
27
+ return getCore()?.getTile(handle);
28
+ },
29
+ getParentTile: (handle) => {
30
+ return getCore()?.getParentTile(handle);
31
+ },
32
+ getTileElevationDecoder: (handle) => {
33
+ return getCore()?.getTileElevationDecoder(handle);
34
+ },
35
+ getVectorTileStates: (handle) => {
36
+ return getCore()?.getVectorTileStates(handle);
37
+ },
38
+ vectorRevision: deps.getVectorRevision,
39
+ getRasterTileStates: (handle) => {
40
+ return getCore()?.getRasterTileStates(handle);
41
+ },
42
+ rasterRevision: deps.getRasterRevision,
43
+ reportDrapeGpuBytes: (handle, bytes) => {
44
+ getCore()?.reportTerrainDrapeGpuBytes(handle, bytes);
45
+ },
46
+ calcMetersPerTexel: (tileHandle, textureZoom, textureWidth) => {
47
+ return (
48
+ getCore()?.calcMetersPerTexel(tileHandle, textureZoom, textureWidth) ??
49
+ 1.0
50
+ );
51
+ },
52
+ mercatorY,
53
+ };
54
+ }
package/src/index.ts CHANGED
@@ -70,6 +70,7 @@ import {
70
70
  type AnyMeshDesc,
71
71
  } from "./core/BaseHandle";
72
72
  import { Registries } from "./core/Registries";
73
+ import { DeclutterManager, wasmDeclutterKernel } from "./declutter";
73
74
  import {
74
75
  getCompositeAtlasSize,
75
76
  getDefaultDynamicSse,
@@ -95,6 +96,7 @@ import {
95
96
  type WorkerTaskHandler,
96
97
  } from "./event";
97
98
  import { TEXTURE_LOADER } from "./event/loaders";
99
+ import { createTileHandler } from "./event/tileHandler";
98
100
  import { InMemoryBufferStore } from "./inMemoryBufferStore";
99
101
  import { registerInputEvents } from "./input";
100
102
  import { Layer, type LayerEvent } from "./layer";
@@ -222,6 +224,10 @@ export type MemoryStats = {
222
224
  gpuBytesEst: number;
223
225
  externalCpuBytes: number;
224
226
  reservedBytes: number;
227
+ /** Estimated GPU bytes of fixed screen-sized allocations (the
228
+ * postprocessing render-target stack), reported via
229
+ * `Core.setFixedGpuBytes` on init, resize, and pass-list changes. */
230
+ fixedGpuBytes: number;
225
231
  budgetBytes: number | undefined;
226
232
  evictedCount: number;
227
233
  sseMultiplier: number;
@@ -252,7 +258,10 @@ export type Options = {
252
258
  container?: HTMLElement;
253
259
  /** Canvas element for rendering. If not provided, a new canvas is created. */
254
260
  canvas?: HTMLCanvasElement | OffscreenCanvas;
255
- /** Device pixel ratio override. Uses device default if not specified. */
261
+ /** Device pixel ratio override. When not specified, the device value is
262
+ * used, capped at 2 on mobile (1 with `mobileOptimization`) — screen-sized
263
+ * render targets grow quadratically with pixel ratio and dominate mobile
264
+ * GPU memory. An explicit value bypasses the caps. */
256
265
  pixelRatio?: number;
257
266
  /** Disables automatic resize handling on window resize events. */
258
267
  disableAutoResize?: boolean;
@@ -442,6 +451,14 @@ export default class ThreeView<
442
451
  forceUpdate: false,
443
452
  animation: false,
444
453
  };
454
+ /** Shared screen-space label declutterer for text/sprite features. */
455
+ private _declutter = new DeclutterManager(wasmDeclutterKernel);
456
+ /** Reusable Vector2 for the declutter pass's per-frame viewport query. */
457
+ private _declutterSize = new Vector2();
458
+ /** Pending follow-up frame for a throttled declutter pass or an active
459
+ * fade, with the delay it was scheduled at (so a shorter need preempts). */
460
+ private _declutterRetry: ReturnType<typeof setTimeout> | undefined;
461
+ private _declutterRetryDelay = 0;
445
462
  private _isIdle = false;
446
463
  private _uniforms: CommonUniforms;
447
464
 
@@ -634,39 +651,11 @@ export default class ThreeView<
634
651
  private _vectorRevision = 0;
635
652
  // Per-frame snapshot of the WASM raster drape resolution revision, refreshed in `_render`.
636
653
  private _rasterRevision = 0;
637
- private _tileHandler: TileHandler = {
638
- getMartini: (id) => {
639
- return this._core?.getMartini(id);
640
- },
641
- getTile: (handle) => {
642
- return this._core?.getTile(handle);
643
- },
644
- getParentTile: (handle) => {
645
- return this._core?.getParentTile(handle);
646
- },
647
- getTileElevationDecoder: (handle) => {
648
- return this._core?.getTileElevationDecoder(handle);
649
- },
650
- getVectorTileStates: (handle) => {
651
- return this._core?.getVectorTileStates(handle);
652
- },
653
- // Returns the per-frame cached revision (read once in `_render`), so per-tile
654
- // `onBeforeRender` gating costs no WASM-boundary call.
655
- vectorRevision: () => this._vectorRevision,
656
- getRasterTileStates: (handle) => {
657
- return this._core?.getRasterTileStates(handle);
658
- },
659
- rasterRevision: () => this._rasterRevision,
660
- reportDrapeGpuBytes: (handle, bytes) => {
661
- this._core?.reportTerrainDrapeGpuBytes(handle, bytes);
662
- },
663
- calcMetersPerTexel: (tileHandle, textureZoom, textureWidth) => {
664
- return (
665
- this._core?.calcMetersPerTexel(tileHandle, textureZoom, textureWidth) ??
666
- 1.0
667
- );
668
- },
669
- };
654
+ private _tileHandler: TileHandler = createTileHandler({
655
+ getCore: () => this._core,
656
+ getVectorRevision: () => this._vectorRevision,
657
+ getRasterRevision: () => this._rasterRevision,
658
+ });
670
659
  private _globeHandler: GlobeHandler = {
671
660
  getTransparent: () => {
672
661
  return this._core?.getGlobeTransparent();
@@ -805,6 +794,11 @@ export default class ThreeView<
805
794
 
806
795
  private pixelRatioMatchedMedia?: MediaQueryList;
807
796
 
797
+ private fixedGpuBytesReportScheduled = false;
798
+ private fixedGpuBytesResyncPending = false;
799
+ private framesSinceFixedGpuBytesReport = 0;
800
+ private lastReportedFixedGpuBytes = -1;
801
+
808
802
  /**
809
803
  * The built-in attribution (credit) UI. `undefined` when disabled via
810
804
  * `defaultAttribution: false`, or in a worker / no-DOM environment. Feed it
@@ -907,6 +901,7 @@ export default class ThreeView<
907
901
  this._tileTextureCompositor = new TileTextureCompositor({
908
902
  renderer: this._renderer,
909
903
  texturizedSceneByTileCoordinates: this._texturizedSceneByTileCoordinates,
904
+ mercatorY: this._tileHandler.mercatorY,
910
905
  size: getCompositeAtlasSize(this.isMobileOptimized()),
911
906
  });
912
907
 
@@ -929,6 +924,8 @@ export default class ThreeView<
929
924
  });
930
925
 
931
926
  this.renderPassOrchestrator.setSize(width, height);
927
+ this.renderPassOrchestrator.onPassesChanged = () =>
928
+ this._scheduleFixedGpuBytesReport();
932
929
 
933
930
  // Background color
934
931
  const bgColor = options.backgroundColor
@@ -1245,6 +1242,7 @@ export default class ThreeView<
1245
1242
  gpuBytesEst: stats.gpuBytesEst,
1246
1243
  externalCpuBytes: stats.externalCpuBytes,
1247
1244
  reservedBytes: stats.reservedBytes,
1245
+ fixedGpuBytes: stats.fixedGpuBytes,
1248
1246
  budgetBytes: stats.budgetBytes,
1249
1247
  evictedCount: stats.evictedCount,
1250
1248
  sseMultiplier: stats.sseMultiplier,
@@ -1370,6 +1368,10 @@ export default class ThreeView<
1370
1368
  costHints.rasterTileBytes,
1371
1369
  );
1372
1370
 
1371
+ // Passes added before `_core` existed could not be reported; sync the
1372
+ // fixed render-target footprint now that the ledger is reachable.
1373
+ this._scheduleFixedGpuBytesReport();
1374
+
1373
1375
  // LOD fog: stronger distance-based degrade on low-memory devices.
1374
1376
  const lodFog = {
1375
1377
  ...getDefaultLodFog({ isMobile: mobileOptimized }),
@@ -1440,6 +1442,7 @@ export default class ThreeView<
1440
1442
  textureFragmentIndex: this._textureFragmentIndex,
1441
1443
  tileMeshToFragmentIds: this._tileMeshToFragmentIds,
1442
1444
  hillshadeContext: this._hillshadeContext,
1445
+ declutter: this._declutter,
1443
1446
  });
1444
1447
 
1445
1448
  // Register built-in descriptors
@@ -1544,6 +1547,10 @@ export default class ThreeView<
1544
1547
  dispose() {
1545
1548
  this._disposed = true;
1546
1549
  this._initialized = false;
1550
+ if (this._declutterRetry !== undefined) {
1551
+ clearTimeout(this._declutterRetry);
1552
+ this._declutterRetry = undefined;
1553
+ }
1547
1554
  // Dispose the view-owned attribution plugin (other plugins are the caller's).
1548
1555
  this._attribution?.dispose();
1549
1556
  this._attribution = undefined;
@@ -1593,6 +1600,10 @@ export default class ThreeView<
1593
1600
  // Cleanup hillshade context (temp DEMs, generator, etc.)
1594
1601
  this._hillshadeContext.dispose();
1595
1602
 
1603
+ // Drop declutter participants so the manager doesn't retain removed
1604
+ // meshes' GPU/font resources past this view's lifetime.
1605
+ this._declutter.dispose();
1606
+
1596
1607
  // Clear caches and maps
1597
1608
  this._meshes.clear();
1598
1609
  this._inMemoryBufferStore.clear();
@@ -1661,10 +1672,41 @@ export default class ThreeView<
1661
1672
  this._pickHelper?.setSize(drawingBufferSize.x, drawingBufferSize.y);
1662
1673
 
1663
1674
  this._core?.resize(w, h, pixelRatio ?? 1);
1675
+ this._scheduleFixedGpuBytesReport();
1664
1676
 
1665
1677
  this.emit("resize", w, h);
1666
1678
  };
1667
1679
 
1680
+ /**
1681
+ * Walks the fixed render-target stack and pushes the estimate into the
1682
+ * memory ledger; the WASM call is skipped while the value is unchanged.
1683
+ */
1684
+ private _reportFixedGpuBytes(): void {
1685
+ if (this._disposed || !this._core) return;
1686
+ const bytes = this.renderPassOrchestrator.estimateFixedGpuBytes();
1687
+ this.framesSinceFixedGpuBytesReport = 0;
1688
+ if (bytes === this.lastReportedFixedGpuBytes) return;
1689
+ this.lastReportedFixedGpuBytes = bytes;
1690
+ this._core.setFixedGpuBytes(bytes);
1691
+ }
1692
+
1693
+ /**
1694
+ * Re-reports the fixed GPU footprint for event-triggered changes (init,
1695
+ * resize, pass add/remove). Scheduled as a microtask so bursts — e.g.
1696
+ * effect setup during init — coalesce into a single walk, and marks a
1697
+ * one-shot post-render resync because these events run before the GPU has
1698
+ * lazily allocated the affected targets.
1699
+ */
1700
+ private _scheduleFixedGpuBytesReport(): void {
1701
+ this.fixedGpuBytesResyncPending = true;
1702
+ if (this.fixedGpuBytesReportScheduled) return;
1703
+ this.fixedGpuBytesReportScheduled = true;
1704
+ queueMicrotask(() => {
1705
+ this.fixedGpuBytesReportScheduled = false;
1706
+ this._reportFixedGpuBytes();
1707
+ });
1708
+ }
1709
+
1668
1710
  private _updateUniforms() {
1669
1711
  const viewport = this._getCanvasSize();
1670
1712
  const pixelRatio = this._renderer.getPixelRatio();
@@ -1744,6 +1786,25 @@ export default class ThreeView<
1744
1786
  }
1745
1787
  }
1746
1788
 
1789
+ /**
1790
+ * Ask for a future frame on behalf of the declutter pass. Runs through a
1791
+ * timer because the main loop clears `forceUpdate` right after `_render` —
1792
+ * a flag set synchronously here would be wiped before the next tick reads
1793
+ * it. A pending longer timer is preempted when a shorter delay is needed
1794
+ * (a fade must not wait out a throttle window).
1795
+ */
1796
+ private _scheduleDeclutterFrame(delayMs: number) {
1797
+ if (this._declutterRetry !== undefined) {
1798
+ if (delayMs >= this._declutterRetryDelay) return;
1799
+ clearTimeout(this._declutterRetry);
1800
+ }
1801
+ this._declutterRetryDelay = delayMs;
1802
+ this._declutterRetry = setTimeout(() => {
1803
+ this._declutterRetry = undefined;
1804
+ this._renderFlag.forceUpdate = true;
1805
+ }, delayMs);
1806
+ }
1807
+
1747
1808
  private _render(updatedAt: number) {
1748
1809
  // Read the vector/raster resolution revisions once per frame so each terrain
1749
1810
  // tile's `onBeforeRender` can gate its slot re-fetch against them without its
@@ -1755,6 +1816,29 @@ export default class ThreeView<
1755
1816
 
1756
1817
  this._atmosphere._update();
1757
1818
 
1819
+ // Screen-space label decluttering runs before the render passes so
1820
+ // placement changes land in this frame. Throttled passes and active
1821
+ // fades both need future frames, requested via _scheduleDeclutterFrame.
1822
+ {
1823
+ const size = this._renderer.getDrawingBufferSize(this._declutterSize);
1824
+ const pixelRatio = this._renderer.getPixelRatio();
1825
+ const result = this._declutter.update(
1826
+ this._camera.raw,
1827
+ size.x / pixelRatio,
1828
+ size.y / pixelRatio,
1829
+ updatedAt,
1830
+ );
1831
+ if (result === "throttled") {
1832
+ this._scheduleDeclutterFrame(
1833
+ this._declutter.remainingThrottleMs(updatedAt),
1834
+ );
1835
+ } else if (result === "animating") {
1836
+ // Keep frames coming at fade cadence until every label reaches its
1837
+ // placement target.
1838
+ this._scheduleDeclutterFrame(16);
1839
+ }
1840
+ }
1841
+
1758
1842
  this.emit("preRender", updatedAt);
1759
1843
 
1760
1844
  this.renderPassOrchestrator.render();
@@ -1763,6 +1847,19 @@ export default class ThreeView<
1763
1847
  this.shadowMapViewers.render(this._renderer);
1764
1848
 
1765
1849
  this.emit("postRender", updatedAt);
1850
+
1851
+ // Render targets are GL-allocated lazily during rendering (and freed on
1852
+ // resize), so a footprint taken at init/resize/pass-change time goes
1853
+ // stale: re-estimate once on the first frame after such an event, and
1854
+ // otherwise at a low cadence. The report skips the WASM call when the
1855
+ // value is unchanged.
1856
+ if (this.fixedGpuBytesResyncPending) {
1857
+ this.fixedGpuBytesResyncPending = false;
1858
+ this._reportFixedGpuBytes();
1859
+ } else if (++this.framesSinceFixedGpuBytesReport >= 120) {
1860
+ this.framesSinceFixedGpuBytesReport = 0;
1861
+ this._reportFixedGpuBytes();
1862
+ }
1766
1863
  }
1767
1864
 
1768
1865
  /**
@@ -2448,9 +2545,14 @@ export default class ThreeView<
2448
2545
  const { width, height } = this._getCanvasSize() ?? {};
2449
2546
  if (!width || !height) return;
2450
2547
 
2548
+ // Route through getDevicePixelRatio so a devicePixelRatio change (zoom,
2549
+ // monitor move) cannot bypass the mobile caps applied at init.
2451
2550
  const pixelRatio = isWorker()
2452
2551
  ? (this._options.pixelRatio ?? 1)
2453
- : window.devicePixelRatio;
2552
+ : getDevicePixelRatio({
2553
+ override: this._options.pixelRatio,
2554
+ mobileOptimization: this._options.mobileOptimization,
2555
+ });
2454
2556
  this.resize(width, height, pixelRatio);
2455
2557
  };
2456
2558
 
@@ -13,8 +13,7 @@ type PickMarker<Markers extends ShaderMarkers, Key extends keyof Markers> = {
13
13
  }[keyof Markers[Key]];
14
14
 
15
15
  type PickMarkers<Markers extends ShaderMarkers> =
16
- | PickMarker<Markers, "vertex">
17
- | PickMarker<Markers, "fragment">;
16
+ PickMarker<Markers, "vertex"> | PickMarker<Markers, "fragment">;
18
17
 
19
18
  /**
20
19
  * A type-safe shader string replacer that restricts operations to declared markers.