@navaramap/three 0.0.0 → 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 (66) 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-665OLxjy.wasm → navara_wasm_font_worker_bg-CkZP9l2i.wasm} +0 -0
  7. package/dist/assets/{navara_wasm_worker_bg-BXhKC5bE.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 +587 -67
  11. package/dist/index.js +5640 -4944
  12. package/dist/index.js.map +1 -1
  13. package/dist/index.umd.cjs +238 -211
  14. package/dist/index.umd.cjs.map +1 -1
  15. package/package.json +35 -34
  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 +17 -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 +143 -33
  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 +19 -4
  38. package/src/material/enhancer/tileComposite/tileElevationHeatmapEnhancer/shader.ts +9 -2
  39. package/src/mesh/batchTexture.ts +2 -4
  40. package/src/mesh/batchedSdfText.ts +61 -1
  41. package/src/mesh/sdfText.ts +170 -5
  42. package/src/mesh/sprite/instancedSprite.ts +245 -3
  43. package/src/mesh/tile/drapeResolver.ts +76 -0
  44. package/src/mesh/{tile.test.ts → tile/index.test.ts} +16 -6
  45. package/src/mesh/{tile.ts → tile/index.ts} +124 -392
  46. package/src/mesh/tile/rasterDrapeResolver.ts +423 -0
  47. package/src/mesh/tile/vectorDrapeResolver.ts +404 -0
  48. package/src/orchestrators/RenderPassOrchestrator.ts +25 -0
  49. package/src/passes/CustomRenderPass.ts +21 -5
  50. package/src/passes/RenderTargetCopyPass.ts +24 -2
  51. package/src/test-utils/engine.ts +53 -0
  52. package/src/tileTexture/TileTextureCache.test.ts +2 -0
  53. package/src/tileTexture/TileTextureCache.ts +1 -0
  54. package/src/tileTexture/TileTextureCompositor.test.ts +107 -0
  55. package/src/tileTexture/TileTextureCompositor.ts +248 -32
  56. package/src/tileTexture/__snapshots__/CompositeShader.snapshot.test.ts.snap +36 -16
  57. package/src/tileTexture/types.ts +7 -0
  58. package/src/utils/demNoDataColor.test.ts +47 -0
  59. package/src/utils/demNoDataColor.ts +48 -0
  60. package/src/utils/fixedGpuFootprint.test.ts +110 -0
  61. package/src/utils/fixedGpuFootprint.ts +162 -0
  62. package/src/worker/tasks/toCreasedNormals.ts +2 -7
  63. package/dist/assets/fontWorker-CEJEfRpb.js +0 -2
  64. package/dist/assets/fontWorker-CEJEfRpb.js.map +0 -1
  65. package/dist/assets/index-BUB-ijMO.js +0 -12
  66. package/dist/assets/index-BUB-ijMO.js.map +0 -1
@@ -0,0 +1,404 @@
1
+ import type { TileHandle } from "@navaramap/core";
2
+ import {
3
+ Color,
4
+ DataTexture,
5
+ RGBAFormat,
6
+ WebGLRenderTarget,
7
+ type Object3D,
8
+ type Vector2,
9
+ } from "three";
10
+
11
+ import type { TileHandler } from "../../event/context";
12
+ import type { TexturizedSceneByTileCoordinates } from "../../scene";
13
+ import type { TextureOptions } from "../../textures";
14
+ import type { TileTextureCompositor } from "../../tileTexture";
15
+ import { PolygonMesh } from "../polygon";
16
+ import { PolylineMesh } from "../polyline";
17
+
18
+ import {
19
+ configureTexturizedTexture,
20
+ type DrapeResolver,
21
+ } from "./drapeResolver";
22
+
23
+ import type { TileMaterial } from ".";
24
+
25
+ /**
26
+ * One WebMercator vector source tile baked into a layer's render target.
27
+ * `tileHandle` keys the cached offscreen scene; `uvOffset`/`uvScale` are the
28
+ * **mercator** affine framing the terrain tile's sub-region of that (possibly
29
+ * coarser, ancestor-resolved) source tile, driving the bake camera (identity for
30
+ * an exact same-tile drape).
31
+ */
32
+ type VectorSource = {
33
+ tileHandle: TileHandle;
34
+ uvOffset: [number, number];
35
+ uvScale: [number, number];
36
+ };
37
+
38
+ /**
39
+ * One clamp-to-ground vector layer draped on this terrain tile, resolved by Rust.
40
+ * A layer is backed by one source on WebMercator terrain, but by several when the
41
+ * terrain is Geographic (N:M): every overlapping WM vector tile is baked into the
42
+ * layer's single render target, each framed by its own `uvOffset`/`uvScale`, so
43
+ * the RT ends up spanning the terrain tile's extent. `reproject` carries the
44
+ * terrain `[south, north]` band on Geographic terrain (undefined on WM) so the
45
+ * composite paste can remap the latitude axis WebMercator→Geographic, exactly
46
+ * like a raster slot.
47
+ */
48
+ type VectorSlot = {
49
+ layerId: string;
50
+ sources: VectorSource[];
51
+ reproject?: [number, number];
52
+ };
53
+
54
+ /** Shared 1×1 transparent texture bound to texturized sampler slots that have
55
+ * no drape render target yet (they are allocated lazily). WebGL requires every
56
+ * sampler to reference a valid texture; the shader gates sampling with `shows`,
57
+ * so this placeholder's content is never visible. */
58
+ const EMPTY_TEXTURIZED_TEXTURE = new DataTexture(
59
+ new Uint8Array([0, 0, 0, 0]),
60
+ 1,
61
+ 1,
62
+ RGBAFormat,
63
+ );
64
+ EMPTY_TEXTURIZED_TEXTURE.needsUpdate = true;
65
+
66
+ /** The slice of TileMesh the vector resolver works against. `material` is an
67
+ * accessor because the tile's material instance can be swapped over its life
68
+ * (Basic ↔ Lambert). */
69
+ export type VectorDrapeHost = {
70
+ handle: TileHandle;
71
+ tileHandler: TileHandler;
72
+ compositor: TileTextureCompositor;
73
+ /** Offscreen per-(tile, layer) vector scenes the bake rasterizes. */
74
+ texturizedScenes: TexturizedSceneByTileCoordinates;
75
+ textureOptions: TextureOptions;
76
+ /** Drape render target side length in texels. */
77
+ drapeRtSize: number;
78
+ /** Texturized-vector slot budget (the region's width). */
79
+ numTexturizedVector: number;
80
+ /** First texturized-vector slot index (the region is `[from, maxTextures)`). */
81
+ texturizedSceneIndexFrom: number;
82
+ material: () => TileMaterial;
83
+ /** Re-report the tile's drape GPU footprint after the RT pool changed. */
84
+ reportDrapeGpuBytes: () => void;
85
+ };
86
+
87
+ /**
88
+ * Clamp-to-ground vector layers: always the baked path, on every tiling scheme.
89
+ * Vector content is an offscreen three.js scene, so rasterizing into a
90
+ * per-layer render target is intrinsic — WebMercator terrain is just the
91
+ * degenerate case (one source, identity affine, no reprojection band) of the
92
+ * Geographic N:M mosaic. That is why, unlike the raster side, there is no
93
+ * direct/baked split here.
94
+ */
95
+ export class VectorDrapeResolver implements DrapeResolver {
96
+ // Per-layer texturized-vector slots resolved by Rust: one slot per
97
+ // clamp-to-ground layer, keyed by the WM vector tile whose offscreen scene
98
+ // backs it. Ancestor fallback is already resolved in Rust, so this side just
99
+ // caches + bakes.
100
+ private vectorSlots: VectorSlot[] = [];
101
+ private renderTargets: WebGLRenderTarget[] = [];
102
+ // Signature of the last baked slot set; a change drives a re-bake + re-bind.
103
+ private prevSignature = "";
104
+ // The vector resolution revision at which `vectorSlots` was last fetched.
105
+ // Re-fetching is skipped while the global revision is unchanged (the resolve
106
+ // result can only change when a traverse runs or a scene becomes ready).
107
+ // `-1` forces a fetch on the first frame.
108
+ private lastRevision = -1;
109
+ private warnedExceededTextures = false;
110
+
111
+ constructor(private readonly host: VectorDrapeHost) {}
112
+
113
+ update(): void {
114
+ // Re-fetch the Rust-resolved slots only when the global vector resolution
115
+ // revision changed; otherwise reuse the cached slots. This skips a per-tile
116
+ // WASM-boundary resolve every frame, the dominant steady-state cost when
117
+ // many terrain tiles are visible.
118
+ const revision = this.host.tileHandler.vectorRevision();
119
+ if (revision !== this.lastRevision) {
120
+ this.lastRevision = revision;
121
+ this.refreshSlots();
122
+ }
123
+
124
+ // Re-bake the offscreen vector scenes only when the resolved slots or their
125
+ // backing scenes changed; otherwise the existing render targets stay valid.
126
+ const signature = this.signature();
127
+ if (signature !== this.prevSignature) {
128
+ this.prevSignature = signature;
129
+ this.host.compositor.renderVectorScenes(
130
+ this.vectorSlots,
131
+ this.renderTargets,
132
+ );
133
+ this.bindSlots();
134
+ this.host.compositor.markDirty(this.host.handle, "vector-revision");
135
+ }
136
+ }
137
+
138
+ syncMaterialSlots(): void {
139
+ const m = this.host.material();
140
+ const textures = m.userData.textures.value;
141
+ for (let i = 0; i < this.host.numTexturizedVector; i++) {
142
+ // Every texturized sampler slot must reference a valid texture (GLSL/WebGL
143
+ // requires it even for a sampler never read). Drape render targets are
144
+ // allocated lazily, so a slot without one binds the shared empty texture;
145
+ // `bindSlots` swaps in the real RT texture once a layer drapes here.
146
+ const lastIndex = this.host.texturizedSceneIndexFrom + i;
147
+ const rt = this.renderTargets[i];
148
+ textures[lastIndex] = rt
149
+ ? configureTexturizedTexture(rt.texture, this.host.textureOptions)
150
+ : EMPTY_TEXTURIZED_TEXTURE;
151
+
152
+ // Per-slot visibility + material attributes are bound by bindSlots once
153
+ // the Rust-resolved slots are known; default to hidden here.
154
+ m.userData.shows.value[lastIndex] = 0;
155
+ m.userData.colors.value[lastIndex] = new Color(0xffffff);
156
+ m.userData.opacities.value[lastIndex] = 1.0;
157
+ }
158
+ // Re-bind vector slots on the next frame now that render targets were rebound.
159
+ this.prevSignature = "";
160
+ }
161
+
162
+ slotReproject(absSlot: number): [number, number] | undefined {
163
+ // Baked-vector slots (whose RT spans the terrain extent) carry the
164
+ // Rust-resolved terrain `[south, north]` band directly on the slot;
165
+ // undefined on WebMercator terrain.
166
+ return this.vectorSlots[absSlot - this.host.texturizedSceneIndexFrom]
167
+ ?.reproject;
168
+ }
169
+
170
+ invalidate(): void {
171
+ this.prevSignature = "";
172
+ }
173
+
174
+ liveRenderTargetCount(): number {
175
+ return this.renderTargets.length;
176
+ }
177
+
178
+ dispose(): void {
179
+ for (const rt of this.renderTargets) {
180
+ rt.dispose();
181
+ }
182
+ this.renderTargets.length = 0;
183
+ }
184
+
185
+ /**
186
+ * Pull the Rust-resolved texturized-vector slots for this terrain tile into
187
+ * {@link vectorSlots}: one bake slot per clamp-to-ground layer, keyed by the
188
+ * rendered WM vector tile that backs it (ancestor fallback already resolved in
189
+ * Rust). Only layers with an actual draped scene take a slot — Rust also returns
190
+ * non-draped layers (rendered in the MRT scene), which must not consume the budget.
191
+ * Capped to the GPU slot budget; warns once when more draped layers are requested.
192
+ */
193
+ private refreshSlots() {
194
+ const states =
195
+ this.host.tileHandler.getVectorTileStates(this.host.handle) ?? [];
196
+
197
+ // Group the flattened per-(layer, source) states back into one slot per
198
+ // layer, each carrying every overlapping WM source tile (N:M on Geographic
199
+ // terrain). First-seen layer order assigns the render-target index, kept
200
+ // consistent by renderVectorScenes / bindSlots / buildCompositeLayers.
201
+ const byLayer = new Map<string, VectorSlot>();
202
+ const droppedLayers = new Set<string>();
203
+ for (const state of states) {
204
+ // Read each wasm getter once — every access crosses the boundary and the
205
+ // uv getters allocate a fresh Float32Array.
206
+ const layerId = state.layer_id;
207
+ const tileHandle = state.tile_handle;
208
+ let slot = byLayer.get(layerId);
209
+ if (!slot) {
210
+ // Rust returns a state for every vector layer with a rendered tile, including
211
+ // non-draped layers (those render in the MRT scene and never create a texturized
212
+ // scene). Only a layer that actually has a draped scene may consume a bake slot;
213
+ // otherwise non-draped layers would occupy the limited slot budget and crowd out
214
+ // genuinely-draped layers past the cap. A draped layer whose scene isn't ready yet
215
+ // simply gets no slot this pass and is picked up once its scene exists.
216
+ const hasScene =
217
+ this.host.texturizedScenes.findSceneByLayerId(tileHandle, layerId) !=
218
+ null;
219
+ if (!hasScene) {
220
+ state.free();
221
+ continue;
222
+ }
223
+ // Cap the layer fan-out to the GPU slot budget; extra layers are dropped.
224
+ if (byLayer.size >= this.host.numTexturizedVector) {
225
+ droppedLayers.add(layerId);
226
+ state.free();
227
+ continue;
228
+ }
229
+ const reproject = state.reproject_terrain_lat;
230
+ slot = {
231
+ layerId,
232
+ sources: [],
233
+ reproject:
234
+ reproject.length >= 2 ? [reproject[0], reproject[1]] : undefined,
235
+ };
236
+ byLayer.set(layerId, slot);
237
+ }
238
+ const uvOffset = state.uv_offset;
239
+ const uvScale = state.uv_scale;
240
+ slot.sources.push({
241
+ tileHandle,
242
+ uvOffset: [uvOffset[0] ?? 0, uvOffset[1] ?? 0],
243
+ uvScale: [uvScale[0] ?? 1, uvScale[1] ?? 1],
244
+ });
245
+ // wasm-bindgen objects hold Rust-heap memory; release it deterministically
246
+ // instead of waiting for the GC's finalizer (see guide/WASM_API_POLICY.md).
247
+ state.free();
248
+ }
249
+
250
+ if (droppedLayers.size > 0) {
251
+ if (!this.warnedExceededTextures) {
252
+ this.warnedExceededTextures = true;
253
+ console.warn(
254
+ `[TileMesh] Exceeded maximum MVT texture slots: ${byLayer.size + droppedLayers.size} layers requested, ` +
255
+ `but only ${this.host.numTexturizedVector} slots available. ` +
256
+ `Some MVT layers will not be rendered.`,
257
+ );
258
+ }
259
+ } else {
260
+ this.warnedExceededTextures = false;
261
+ }
262
+
263
+ this.vectorSlots = [...byLayer.values()];
264
+ this.syncRenderTargets();
265
+ }
266
+
267
+ /**
268
+ * Size the drape render-target pool to the live slot count (already capped to
269
+ * `numTexturizedVector` by {@link refreshSlots}): allocate the ones a
270
+ * newly-draped layer needs, dispose the ones a vanished layer freed. Then
271
+ * report the pool's GPU footprint to the memory ledger so it tracks drape
272
+ * memory that scales with terrain subdivision past the vector maxZoom — a cost
273
+ * per-vector-tile accounting cannot see. Reports only when the count changes.
274
+ */
275
+ private syncRenderTargets() {
276
+ const target = this.vectorSlots.length;
277
+ const rts = this.renderTargets;
278
+ while (rts.length < target) {
279
+ const rt = new WebGLRenderTarget(
280
+ this.host.drapeRtSize,
281
+ this.host.drapeRtSize,
282
+ {
283
+ format: RGBAFormat,
284
+ },
285
+ );
286
+ // Match the sampler settings syncMaterialSlots applies to rebound RTs,
287
+ // so the lazily-grown one binds identically when bindSlots picks it up.
288
+ configureTexturizedTexture(rt.texture, this.host.textureOptions);
289
+ rts.push(rt);
290
+ }
291
+ while (rts.length > target) {
292
+ rts.pop()?.dispose();
293
+ }
294
+
295
+ this.host.reportDrapeGpuBytes();
296
+ }
297
+
298
+ /**
299
+ * Identity of the current slot set + the backing scenes' content. A change means
300
+ * the offscreen scenes must be re-baked and the per-slot material state re-bound.
301
+ */
302
+ private signature(): string {
303
+ return this.vectorSlots
304
+ .map((slot) => {
305
+ const sourceSig = slot.sources
306
+ .map((s) => {
307
+ const scene = this.host.texturizedScenes.findSceneByLayerId(
308
+ s.tileHandle,
309
+ slot.layerId,
310
+ );
311
+ return `${s.tileHandle}@${s.uvOffset[0]},${s.uvOffset[1]},${s.uvScale[0]},${s.uvScale[1]}#${scene?.revision ?? -1}:${scene?.children.length ?? 0}`;
312
+ })
313
+ .join(",");
314
+ return `${slot.layerId}[${sourceSig}]`;
315
+ })
316
+ .join("|");
317
+ }
318
+
319
+ /**
320
+ * Drive each vector slot's main-shader state: texture, per-slot UV transform,
321
+ * visibility and the draped mesh's material attributes. One slot per layer,
322
+ * sourced from the WM vector tile the Rust resolve picked. The resolve walks
323
+ * up to the nearest rendered tile (readiness derived from ECS activation /
324
+ * the Rust resolve, not a JS-side scene_ready flag), so the slot's scene is
325
+ * always bakeable (its own render target was framed by the bake) and the UV
326
+ * is identity — the ancestor LOD fallback lives entirely in Rust.
327
+ */
328
+ private bindSlots() {
329
+ const m = this.host.material();
330
+ if (!m || !m.userData || !m.userData.textures?.value) return;
331
+ const textures = m.userData.textures.value;
332
+ const uvOffsets: Vector2[] = m.userData.layerUvOffset?.value ?? [];
333
+ const uvScales: Vector2[] = m.userData.layerUvScale?.value ?? [];
334
+
335
+ for (let i = 0; i < this.host.numTexturizedVector; i++) {
336
+ const lastIdx = this.host.texturizedSceneIndexFrom + i;
337
+ const ownRt = this.renderTargets[i];
338
+ // Slots past the live pool (lazily sized to the draped-layer count) have
339
+ // no render target: hide the slot and rebind the shared empty texture so
340
+ // the shader never samples a stale / disposed texture from a layer that
341
+ // is no longer draped here.
342
+ if (!ownRt) {
343
+ m.userData.shows.value[lastIdx] = 0;
344
+ textures[lastIdx] = EMPTY_TEXTURIZED_TEXTURE;
345
+ continue;
346
+ }
347
+
348
+ textures[lastIdx] = ownRt.texture;
349
+ // The bake framed every source into this RT so it spans the terrain tile's
350
+ // extent: the paste samples it 1:1 in longitude (identity UV here); the
351
+ // latitude axis is reprojected WebMercator→Geographic in the composite
352
+ // shader via the slot's reproject band (see `slotReproject`).
353
+ uvOffsets[lastIdx]?.set(0, 0);
354
+ uvScales[lastIdx]?.set(1, 1);
355
+
356
+ const slot = this.vectorSlots[i];
357
+ const mesh = slot ? this.representativeMesh(slot) : undefined;
358
+ m.userData.shows.value[lastIdx] = mesh ? 1 : 0;
359
+ if (mesh) this.copyMeshAttrs(lastIdx, mesh);
360
+ }
361
+ }
362
+
363
+ /**
364
+ * First available draped mesh backing any of a layer's sources. A clamp-to-ground
365
+ * layer's material attributes (water/specular/emissive/…) are uniform across its
366
+ * tiles, so any source's mesh is a faithful representative for the slot.
367
+ */
368
+ private representativeMesh(slot: VectorSlot): Object3D | undefined {
369
+ for (const source of slot.sources) {
370
+ const scene = this.host.texturizedScenes.findSceneByLayerId(
371
+ source.tileHandle,
372
+ slot.layerId,
373
+ );
374
+ if (scene && !scene.removed && scene.children.length) {
375
+ return scene.children[0];
376
+ }
377
+ }
378
+ return undefined;
379
+ }
380
+
381
+ /** Copy a draped mesh's material attributes into the main shader's slot `lastIdx`. */
382
+ private copyMeshAttrs(lastIdx: number, mesh: Object3D) {
383
+ const m = this.host.material();
384
+ if (mesh instanceof PolygonMesh) {
385
+ // Use PolygonMesh getters that expose material enhancer state
386
+ m.userData.reflectivities.value[lastIdx] = mesh.reflectivity;
387
+ m.userData.roughnesses.value[lastIdx] = mesh.roughness;
388
+ m.userData.waters.value[lastIdx] = mesh.water;
389
+ m.userData.waterScaleNormals.value[lastIdx] = mesh.waterScaleNormal;
390
+ m.userData.waterSpeeds.value[lastIdx] = mesh.waterSpeed;
391
+ m.userData.shininesses.value[lastIdx] = mesh.shininess;
392
+ m.userData.specularStrengths.value[lastIdx] = mesh.specularStrength;
393
+ m.userData.applyWaterNormals.value[lastIdx] = mesh.applyWaterNormal;
394
+ m.userData.speculars.value[lastIdx] = mesh.specular;
395
+ m.userData.emissiveIntensities.value[lastIdx] = mesh.emissiveIntensity;
396
+ m.userData.emissiveColors.value[lastIdx].set(mesh.emissiveColor);
397
+ m.userData.effectIdsMasks.value[lastIdx] = mesh.effectIdsMask;
398
+ } else if (mesh instanceof PolylineMesh) {
399
+ m.userData.emissiveIntensities.value[lastIdx] = mesh.emissiveIntensity;
400
+ m.userData.emissiveColors.value[lastIdx].set(mesh.emissiveColor);
401
+ m.userData.effectIdsMasks.value[lastIdx] = mesh.effectIdsMask;
402
+ }
403
+ }
404
+ }
@@ -2,6 +2,8 @@ import { NamedIndexMap } from "@navaramap/core";
2
2
  import { EffectComposer, Pass as PostProcessingPass } from "postprocessing";
3
3
  import { HalfFloatType, Scene, WebGLRenderer, Group } from "three";
4
4
 
5
+ import { estimateFixedGpuBytes } from "../utils/fixedGpuFootprint";
6
+
5
7
  export type RenderPassOrchestratorOptions = {
6
8
  halfFloat?: boolean;
7
9
  multisampling?: number;
@@ -34,6 +36,12 @@ export class RenderPassOrchestrator {
34
36
  };
35
37
  effectComposer: EffectComposer;
36
38
 
39
+ /**
40
+ * Invoked whenever the pass list changes (add/insert/remove/clear), so the
41
+ * owner can re-report the fixed GPU footprint to the memory ledger.
42
+ */
43
+ onPassesChanged?: () => void;
44
+
37
45
  private passMap = new NamedIndexMap<NamedPass>();
38
46
 
39
47
  constructor(renderer: WebGLRenderer, options: RenderPassOrchestratorOptions) {
@@ -60,6 +68,7 @@ export class RenderPassOrchestrator {
60
68
  const namedPass: NamedPass = { name, pass };
61
69
  this.passMap.add(namedPass);
62
70
  this.effectComposer.addPass(pass);
71
+ this.onPassesChanged?.();
63
72
  }
64
73
 
65
74
  /**
@@ -73,6 +82,7 @@ export class RenderPassOrchestrator {
73
82
  const namedPass: NamedPass = { name, pass };
74
83
  const targetIndex = this.passMap.insertBefore(targetName, namedPass);
75
84
  this.effectComposer.addPass(pass, targetIndex);
85
+ this.onPassesChanged?.();
76
86
  }
77
87
 
78
88
  /**
@@ -86,6 +96,7 @@ export class RenderPassOrchestrator {
86
96
  const namedPass: NamedPass = { name, pass };
87
97
  const targetIndex = this.passMap.insertAfter(targetName, namedPass);
88
98
  this.effectComposer.addPass(pass, targetIndex);
99
+ this.onPassesChanged?.();
89
100
  }
90
101
 
91
102
  /**
@@ -100,6 +111,7 @@ export class RenderPassOrchestrator {
100
111
  this.effectComposer.removePass(targetPass.pass);
101
112
  this.passMap.list = this.passMap.list.filter((p) => p.name !== name);
102
113
  this.rebuildIndexMap();
114
+ this.onPassesChanged?.();
103
115
  }
104
116
 
105
117
  /**
@@ -125,6 +137,19 @@ export class RenderPassOrchestrator {
125
137
  }
126
138
  this.passMap.list = [];
127
139
  this.passMap.indexMap = {};
140
+ this.onPassesChanged?.();
141
+ }
142
+
143
+ /**
144
+ * Estimates the resident GPU bytes of the fixed, screen-sized render-target
145
+ * stack (composer buffers + every target reachable from the passes), for
146
+ * reporting into the memory ledger's `fixed_gpu_bytes` term.
147
+ */
148
+ estimateFixedGpuBytes(): number {
149
+ return estimateFixedGpuBytes(
150
+ this.effectComposer,
151
+ this.passMap.list.map((p) => p.pass),
152
+ );
128
153
  }
129
154
 
130
155
  /**
@@ -2,11 +2,13 @@ import { Globe } from "@navaramap/core";
2
2
  import { DepthCopyPass } from "postprocessing";
3
3
  import {
4
4
  Color,
5
+ DepthStencilFormat,
5
6
  DepthTexture,
6
7
  HalfFloatType,
7
8
  NearestFilter,
8
9
  RGBADepthPacking,
9
10
  Scene,
11
+ UnsignedInt248Type,
10
12
  WebGLRenderTarget,
11
13
  type PerspectiveCamera,
12
14
  type WebGLRenderer,
@@ -90,7 +92,20 @@ export class CustomRenderPass extends RenderPass {
90
92
  emissiveTexture.type = HalfFloatType;
91
93
  this.gbufferRenderTarget.textures.push(emissiveTexture);
92
94
 
95
+ // The gbuffer owns its depth texture instead of sharing the composer's one
96
+ // (postprocessing >= 6.39 no longer exposes a writable shared depth
97
+ // texture). Depth is propagated to the composer's input buffer explicitly
98
+ // by the copy pass below. Stencil is required for draped-mesh rendering.
99
+ const gbufferDepthTexture = new DepthTexture(
100
+ this.gbufferRenderTarget.width,
101
+ this.gbufferRenderTarget.height,
102
+ UnsignedInt248Type,
103
+ );
104
+ gbufferDepthTexture.format = DepthStencilFormat;
105
+ this.gbufferRenderTarget.depthTexture = gbufferDepthTexture;
106
+
93
107
  this.copyPass = new RenderTargetCopyPass(this.gbufferRenderTarget);
108
+ this.copyPass.setDepthTexture(gbufferDepthTexture);
94
109
 
95
110
  this.globeDepthCopyPass = new DepthCopyPass({
96
111
  depthPacking: RGBADepthPacking,
@@ -240,11 +255,12 @@ export class CustomRenderPass extends RenderPass {
240
255
  this.allDepthCopyPass.render(renderer, null, null);
241
256
  }
242
257
 
243
- setDepthTexture(depthTexture: DepthTexture): void {
244
- this.gbufferRenderTarget.depthTexture = depthTexture;
245
- this.globeDepthCopyPass.setDepthTexture(depthTexture.clone());
246
- this.copyPass.setDepthTexture(depthTexture);
247
- }
258
+ // The composer-provided depth texture is intentionally ignored: the gbuffer
259
+ // owns its depth texture and depth is copied into the input buffer by the
260
+ // copy pass. `needsDepthTexture` stays true so the composer still attaches a
261
+ // depth texture to the input buffer, which `allDepthCopyPass` reads after
262
+ // the opaque scene has been rendered.
263
+ setDepthTexture(): void {}
248
264
 
249
265
  setSize(width: number, height: number) {
250
266
  this.gbufferRenderTarget.setSize(width, height);
@@ -1,6 +1,7 @@
1
1
  import PostProcessingCommon from "@shaders/glsl/postprocessingCommon.vert.glsl";
2
2
  import { Pass } from "postprocessing";
3
3
  import {
4
+ AlwaysDepth,
4
5
  NoBlending,
5
6
  ShaderMaterial,
6
7
  Texture,
@@ -10,13 +11,18 @@ import {
10
11
 
11
12
  const fragmentShader = `
12
13
  uniform sampler2D color;
14
+
15
+ #ifdef COPY_DEPTH
13
16
  uniform sampler2D depth;
17
+ #endif
14
18
 
15
19
  varying vec2 vUv;
16
20
 
17
21
  void main() {
18
22
  gl_FragColor = texture2D(color, vUv);
19
- gl_FragDepth = texture2D(color, vUv).r;
23
+ #ifdef COPY_DEPTH
24
+ gl_FragDepth = texture2D(depth, vUv).r;
25
+ #endif
20
26
  }
21
27
  `;
22
28
 
@@ -71,8 +77,24 @@ export class RenderTargetCopyPass extends Pass {
71
77
  this._material.uniforms.color.value = texture;
72
78
  }
73
79
 
74
- setDepthTexture(texture: Texture): void {
80
+ /**
81
+ * When a depth texture is set, the pass copies its depth into the target's
82
+ * depth buffer via gl_FragDepth. Depth test must be enabled (with AlwaysDepth)
83
+ * because GL discards depth writes while the depth test is disabled.
84
+ */
85
+ setDepthTexture(texture: Texture | null): void {
75
86
  this._material.uniforms.depth.value = texture;
87
+ const copyDepth = texture !== null;
88
+ if ("COPY_DEPTH" in this._material.defines === copyDepth) return;
89
+ if (copyDepth) {
90
+ this._material.defines.COPY_DEPTH = "";
91
+ } else {
92
+ delete this._material.defines.COPY_DEPTH;
93
+ }
94
+ this._material.depthWrite = copyDepth;
95
+ this._material.depthTest = copyDepth;
96
+ this._material.depthFunc = AlwaysDepth;
97
+ this._material.needsUpdate = true;
76
98
  }
77
99
 
78
100
  render(
@@ -0,0 +1,53 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+
4
+ import { Core, initSync } from "@navaramap/engine";
5
+
6
+ import type { TileHandler } from "../event/context";
7
+ import { createTileHandler } from "../event/tileHandler";
8
+
9
+ let engineInitialized = false;
10
+
11
+ /**
12
+ * Initialize the engine WASM once per vitest worker. The web-target module
13
+ * normally streams the binary over `fetch` (`initCore`), which Node cannot do
14
+ * for workspace files — so the bytes are read from the `@navaramap/engine`
15
+ * package and compiled synchronously. Repeat calls are no-ops; every test
16
+ * file that touches Rust-backed functions can call this without coordination.
17
+ */
18
+ export function initTestEngine(): void {
19
+ if (engineInitialized) return;
20
+ const require = createRequire(import.meta.url);
21
+ const wasmPath = require.resolve("@navaramap/engine/navara_wasm_bg.wasm");
22
+ initSync({ module: readFileSync(wasmPath) });
23
+ engineInitialized = true;
24
+ }
25
+
26
+ let sharedCore: Core | undefined;
27
+
28
+ /**
29
+ * Lazily created `Core` shared across a vitest worker's test files.
30
+ * `Core::new` only builds the Bevy app (no DOM access), so it is safe in the
31
+ * Node test environment. Shared deliberately: tests exercise Rust-backed
32
+ * queries against one engine instead of paying a per-file world setup.
33
+ */
34
+ export function getTestCore(): Core {
35
+ initTestEngine();
36
+ sharedCore ??= new Core("navara-test");
37
+ return sharedCore;
38
+ }
39
+
40
+ /**
41
+ * The production {@link TileHandler} assembled over the shared test engine —
42
+ * tests reach the same Rust functions (e.g. `mercatorY`) the app wires through
43
+ * the event context, instead of stubbing them. The `Core` is created lazily on
44
+ * first Core-backed call, so pure-function consumers never construct it.
45
+ */
46
+ export function createTestTileHandler(): TileHandler {
47
+ initTestEngine();
48
+ return createTileHandler({
49
+ getCore: () => getTestCore(),
50
+ getVectorRevision: () => 0,
51
+ getRasterRevision: () => 0,
52
+ });
53
+ }
@@ -46,6 +46,7 @@ describe("TileTextureCache.acquire", () => {
46
46
  expect(reasons?.has("material")).toBe(true);
47
47
  expect(reasons?.has("texture-binding")).toBe(true);
48
48
  expect(reasons?.has("vector-revision")).toBe(true);
49
+ expect(reasons?.has("raster-revision")).toBe(true);
49
50
  expect(reasons?.has("hillshade")).toBe(true);
50
51
  });
51
52
  });
@@ -108,6 +109,7 @@ describe("TileTextureCache atlas pooling", () => {
108
109
  expect(reasons?.has("material")).toBe(true);
109
110
  expect(reasons?.has("texture-binding")).toBe(true);
110
111
  expect(reasons?.has("vector-revision")).toBe(true);
112
+ expect(reasons?.has("raster-revision")).toBe(true);
111
113
  expect(reasons?.has("hillshade")).toBe(true);
112
114
  });
113
115
 
@@ -68,6 +68,7 @@ export class TileTextureCache {
68
68
  "material",
69
69
  "texture-binding",
70
70
  "vector-revision",
71
+ "raster-revision",
71
72
  "hillshade",
72
73
  ]),
73
74
  };