@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
@@ -2,6 +2,7 @@ import { Mesh, BoxGeometry, MeshBasicMaterial } from "three";
2
2
  import { describe, expect, it, vi } from "vitest";
3
3
 
4
4
  import { TexturizedSceneByTileCoordinates } from "../scene";
5
+ import { createTestTileHandler } from "../test-utils/engine";
5
6
 
6
7
  import { TileTextureCompositor } from "./TileTextureCompositor";
7
8
  import type { AtlasFactory, CompositeAtlas } from "./types";
@@ -47,6 +48,7 @@ function setup() {
47
48
  typeof TileTextureCompositor
48
49
  >[0]["renderer"],
49
50
  texturizedSceneByTileCoordinates: texturizedScenes,
51
+ mercatorY: createTestTileHandler().mercatorY,
50
52
  atlasFactory: fakeFactory,
51
53
  });
52
54
  return { compositor, renderer, texturizedScenes };
@@ -258,3 +260,108 @@ describe("TileTextureCompositor.renderVectorScenes", () => {
258
260
  expect(renderer.clear).toHaveBeenCalledTimes(1);
259
261
  });
260
262
  });
263
+
264
+ describe("TileTextureCompositor.renderRasterTiles", () => {
265
+ const fakeRT = (): unknown => ({ texture: { needsUpdate: false } });
266
+ // A bake source texture: `configureRasterBakeSource` mutates these sampler
267
+ // fields in place, so a plain object stands in for a three.js Texture.
268
+ const fakeTex = (name: string): unknown => ({
269
+ name,
270
+ colorSpace: "",
271
+ minFilter: 0,
272
+ magFilter: 0,
273
+ generateMipmaps: false,
274
+ needsUpdate: false,
275
+ });
276
+ /** The texture bound to the bake quad at each render call, in draw order. */
277
+ const trackDrawnMaps = (renderer: ReturnType<typeof makeRenderer>) => {
278
+ const maps: unknown[] = [];
279
+ renderer.render.mockImplementation((scene) => {
280
+ const quad = (scene as { children: { material: { map: unknown } }[] })
281
+ .children[0];
282
+ maps.push(quad.material.map);
283
+ });
284
+ return maps;
285
+ };
286
+
287
+ it("paints the no-data underlay across a heatmap target before its sources", () => {
288
+ const { compositor, renderer } = setup();
289
+ const maps = trackDrawnMaps(renderer);
290
+ const src = fakeTex("dem");
291
+
292
+ compositor.renderRasterTiles(
293
+ [
294
+ {
295
+ isElevationHeatmap: true,
296
+ noDataColor: [1, 2, 3],
297
+ sources: [
298
+ { texture: src as never, uvOffset: [0, 0], uvScale: [1, 1] },
299
+ ],
300
+ },
301
+ ],
302
+ [fakeRT() as never],
303
+ );
304
+
305
+ // First draw: the 1×1 no-data texture (decoder boundary color, opaque
306
+ // alpha) spanning the whole target; the DEM source then overwrites the
307
+ // covered region, leaving uncovered regions decoding as no-data.
308
+ expect(maps).toHaveLength(2);
309
+ const underlay = maps[0] as { image: { data: Uint8Array } };
310
+ expect(Array.from(underlay.image.data)).toEqual([1, 2, 3, 255]);
311
+ expect(maps[1]).toBe(src);
312
+ // The underlay is framed over the full target (identity camera window).
313
+ const cam = renderer.render.mock.calls[0][1] as {
314
+ left: number;
315
+ right: number;
316
+ bottom: number;
317
+ top: number;
318
+ };
319
+ expect([cam.left, cam.right, cam.bottom, cam.top]).toEqual([-1, 1, -1, 1]);
320
+ // Cleared once: underlay and source accumulate into the same target.
321
+ expect(renderer.clear).toHaveBeenCalledTimes(1);
322
+ });
323
+
324
+ it("draws sources coarse-first so finer tiles overwrite the ancestor", () => {
325
+ const { compositor, renderer } = setup();
326
+ const maps = trackDrawnMaps(renderer);
327
+ // A finer-than-terrain tile (uvScale > 1) and a coarse ancestor fallback
328
+ // (uvScale < 1) covering the whole rect, listed fine-first: the bake must
329
+ // reorder by ascending uvScale or the ancestor buries the finer tile.
330
+ const fine = fakeTex("fine");
331
+ const coarse = fakeTex("coarse");
332
+
333
+ compositor.renderRasterTiles(
334
+ [
335
+ {
336
+ isElevationHeatmap: false,
337
+ sources: [
338
+ { texture: fine as never, uvOffset: [0, 0], uvScale: [2, 2] },
339
+ {
340
+ texture: coarse as never,
341
+ uvOffset: [0, 0],
342
+ uvScale: [0.5, 0.5],
343
+ },
344
+ ],
345
+ },
346
+ ],
347
+ [fakeRT() as never],
348
+ );
349
+
350
+ expect(maps).toEqual([coarse, fine]);
351
+ expect(renderer.clear).toHaveBeenCalledTimes(1);
352
+ });
353
+
354
+ it("skips a never-touched render target that has no slot (no GPU allocation)", () => {
355
+ const { compositor, renderer } = setup();
356
+
357
+ const rt = fakeRT();
358
+ compositor.renderRasterTiles([], [rt as never]);
359
+
360
+ // Render-targeting the RT would create its GL framebuffer; an empty slot
361
+ // whose target was never baked must not pay that cost (same invariant as
362
+ // the vector bake — both run through the shared bake loop).
363
+ expect(renderer.setRenderTarget).not.toHaveBeenCalledWith(rt);
364
+ expect(renderer.render).not.toHaveBeenCalled();
365
+ expect(renderer.clear).not.toHaveBeenCalled();
366
+ });
367
+ });
@@ -3,12 +3,18 @@ import {
3
3
  Color,
4
4
  DataTexture,
5
5
  GLSL3,
6
+ LinearFilter,
6
7
  Mesh,
8
+ MeshBasicMaterial,
9
+ NearestFilter,
10
+ NoBlending,
11
+ NoColorSpace,
7
12
  OrthographicCamera,
8
13
  PlaneGeometry,
9
14
  RGBAFormat,
10
15
  Scene,
11
16
  ShaderMaterial,
17
+ SRGBColorSpace,
12
18
  type Texture,
13
19
  UnsignedByteType,
14
20
  WebGLRenderTarget,
@@ -71,6 +77,10 @@ export const defaultAtlasFactory =
71
77
  export type TileTextureCompositorOptions = {
72
78
  renderer: WebGLRenderer;
73
79
  texturizedSceneByTileCoordinates: TexturizedSceneByTileCoordinates;
80
+ /** Rust's clamped WebMercator northing (`TileHandler.mercatorY`), handed to
81
+ * the core uniform mutates so the paste-side reprojection constants come
82
+ * from the same implementation as the Rust bake affine. */
83
+ mercatorY: (lat: number) => number;
74
84
  /** Atlas RT side length (defaults to 512). */
75
85
  size?: number;
76
86
  /** Test seam: replace MRT RT creation. */
@@ -89,6 +99,30 @@ type CachedMaterial = {
89
99
  placeholderTexture: Texture;
90
100
  };
91
101
 
102
+ /** One WebMercator raster tile texture to bake into a layer's render target,
103
+ * framed by the Rust-resolved mercator affine (terrain `[0,1]` UV → source UV). */
104
+ export type RasterBakeSource = {
105
+ texture: Texture;
106
+ uvOffset: [number, number];
107
+ uvScale: [number, number];
108
+ };
109
+
110
+ /** One baked raster layer: its overlapping WM tile textures, mosaicked into one
111
+ * render target. Heatmap layers carry encoded DEM, copied raw (nearest, no
112
+ * color conversion) so the composite decode stays valid. */
113
+ export type RasterBakeSlot = {
114
+ isElevationHeatmap: boolean;
115
+ /**
116
+ * The decoder's no-data (boundary) color for heatmap slots: painted across
117
+ * the render target before the sources so uncovered regions decode as
118
+ * "no elevation" — the no-data story lives entirely in the encoded-elevation
119
+ * domain, keeping the alpha channel free for future RGBA encodings. Absent
120
+ * for color slots and for decoders with no byte-representable boundary.
121
+ */
122
+ noDataColor?: [number, number, number];
123
+ sources: RasterBakeSource[];
124
+ };
125
+
92
126
  /**
93
127
  * Integration layer for per-tile texture composition.
94
128
  *
@@ -112,9 +146,11 @@ export class TileTextureCompositor {
112
146
  * per-tile GPU byte cost it reports to the memory ledger. */
113
147
  readonly size: number;
114
148
  private readonly texturizedScenes: TexturizedSceneByTileCoordinates;
115
- // Single fixed [-1, 1] camera shared by every vector-scene bake. Ancestor
116
- // fallback is resolved in Rust, so no per-tile camera transform is needed.
117
- private readonly vectorCamera = new OrthographicCamera(-1, 1, 1, -1, 0, 1);
149
+ private readonly mercatorY: (lat: number) => number;
150
+ // Single camera shared by every drape bake (vector and raster), re-framed
151
+ // per source by `frameBakeCamera`. Ancestor fallback is resolved in Rust,
152
+ // so no per-tile camera transform is needed.
153
+ private readonly bakeCamera = new OrthographicCamera(-1, 1, 1, -1, 0, 1);
118
154
 
119
155
  // Composite-pass machinery (lazily created on first use so tests that
120
156
  // don't render don't pay the allocation).
@@ -122,15 +158,43 @@ export class TileTextureCompositor {
122
158
  private readonly quadCamera = new OrthographicCamera(-1, 1, 1, -1, 0, 1);
123
159
  private readonly quadMesh: Mesh;
124
160
  private readonly materialCache = new Map<string, CachedMaterial>();
125
- // Vector render targets this compositor has ever rendered to. three.js
161
+ // Drape render targets this compositor has ever rendered to. three.js
126
162
  // allocates a render target's GL framebuffer on first setRenderTarget, so
127
163
  // an empty slot whose target was never touched must be skipped entirely —
128
164
  // clearing it would allocate size² × 4 bytes of GPU memory for nothing.
129
- private readonly touchedVectorTargets = new WeakSet<WebGLRenderTarget>();
165
+ private readonly touchedTargets = new WeakSet<WebGLRenderTarget>();
166
+
167
+ // Raster-bake machinery (lazily used): a textured quad drawn once per source
168
+ // through the same windowing camera as the vector bake. `toneMapped: false` +
169
+ // per-kind texture color spaces keep the bake a value-preserving copy.
170
+ //
171
+ // `transparent: true` + `blending: NoBlending` carries the source ALPHA into
172
+ // the render target: an opaque material (transparent=false + NormalBlending)
173
+ // gets three.js's `OPAQUE` define, whose shader forces `diffuseColor.a = 1.0`
174
+ // and destroys the tile's transparency. NoBlending keeps replace semantics —
175
+ // sources are disjoint WM tiles (plus a coarse-first ancestor underlay), so
176
+ // each draw must overwrite, not blend, and straight (r, g, b, a) texels land
177
+ // in the target for the composite's own alpha blend.
178
+ private readonly rasterBakeScene = new Scene();
179
+ private readonly rasterBakeMaterial = new MeshBasicMaterial({
180
+ toneMapped: false,
181
+ depthTest: false,
182
+ depthWrite: false,
183
+ transparent: true,
184
+ blending: NoBlending,
185
+ });
186
+ // 1×1 no-data underlay for baked heatmap targets, drawn through the same
187
+ // value-preserving bake pipeline as the sources — a texture upload is
188
+ // byte-exact, whereas a clear color would pass through the renderer's color
189
+ // management and could land one byte off (fatal for positional encodings,
190
+ // where the high byte weighs 65536 in decoded x). Cached per color.
191
+ private demNoDataTexture: DataTexture | null = null;
192
+ private demNoDataTextureKey = "";
130
193
 
131
194
  constructor(opts: TileTextureCompositorOptions) {
132
195
  this.renderer = opts.renderer;
133
196
  this.texturizedScenes = opts.texturizedSceneByTileCoordinates;
197
+ this.mercatorY = opts.mercatorY;
134
198
  this.size = opts.size ?? 512;
135
199
  this.cache = new TileTextureCache({
136
200
  size: this.size,
@@ -141,7 +205,15 @@ export class TileTextureCompositor {
141
205
  this.quadMesh = new Mesh(new PlaneGeometry(2, 2));
142
206
  this.quadScene.add(this.quadMesh);
143
207
  this.quadCamera.position.z = 1;
144
- this.vectorCamera.position.z = 1;
208
+ this.bakeCamera.position.z = 1;
209
+
210
+ // Raster-bake quad: spans the source tile's NDC frame like a vector scene,
211
+ // so the same windowing camera math frames it. Shares the fullscreen quad
212
+ // geometry with `quadMesh` (both are static [-1, 1] planes; the windowing
213
+ // lives in the camera), so dispose() frees one geometry for both.
214
+ this.rasterBakeScene.add(
215
+ new Mesh(this.quadMesh.geometry, this.rasterBakeMaterial),
216
+ );
145
217
  }
146
218
 
147
219
  // ---------------------------------------------------------------------
@@ -204,7 +276,88 @@ export class TileTextureCompositor {
204
276
  }[],
205
277
  renderTargets: WebGLRenderTarget[],
206
278
  ): void {
207
- const camera = this.vectorCamera;
279
+ this.bakeSlotTargets(slots, renderTargets, (slot) => {
280
+ for (const source of slot.sources) {
281
+ const scene = this.texturizedScenes.findSceneByLayerId(
282
+ source.tileHandle,
283
+ slot.layerId,
284
+ );
285
+ if (!scene || scene.removed || !scene.children.length) continue;
286
+ this.renderer.render(
287
+ scene,
288
+ this.frameBakeCamera(source.uvOffset, source.uvScale),
289
+ );
290
+ }
291
+ });
292
+ }
293
+
294
+ /**
295
+ * Bake each raster layer's overlapping WM tile textures into ONE render
296
+ * target (slot `i` → `renderTargets[i]`), mirroring {@link renderVectorScenes}:
297
+ * every source is a textured quad framed by the Rust-supplied mercator affine
298
+ * `uvOffset`/`uvScale`, so the render target ends up spanning the terrain
299
+ * tile's (Mercator-projected) extent and the composite treats the whole layer
300
+ * as one slot with the terrain-band reprojection — instead of one composite
301
+ * slot per overlapping tile, which overflowed the GPU slot budget on
302
+ * Geographic terrain once 3+ layers were draped.
303
+ *
304
+ * Sources are drawn coarse-first (ascending `uvScale`): an ancestor fallback
305
+ * covers the whole terrain rect, so painting it first lets the finer tiles
306
+ * overwrite it instead of being buried under coarse texels. The bake is a
307
+ * value-preserving copy (`toneMapped: false`; per-kind color spaces below), so
308
+ * heatmap DEM texels survive it bit-faithfully:
309
+ * - color imagery: source + target declared sRGB — decode at fetch, encode at
310
+ * write, decode again at the composite fetch: the same linear values the
311
+ * direct (non-baked) path produced.
312
+ * - elevation heatmap: everything `NoColorSpace` + nearest — raw texels, no
313
+ * interpolation, so the composite's decode-then-interpolate keeps working.
314
+ */
315
+ renderRasterTiles(
316
+ slots: (RasterBakeSlot | undefined)[],
317
+ renderTargets: (WebGLRenderTarget | undefined)[],
318
+ ): void {
319
+ this.bakeSlotTargets(slots, renderTargets, (slot) => {
320
+ // Heatmap targets: paint the decoder's no-data color across the whole
321
+ // target first, so regions no source covers decode as "no elevation"
322
+ // (the composite renders them transparent) instead of decoding the
323
+ // cleared black as a height.
324
+ if (slot.isElevationHeatmap && slot.noDataColor) {
325
+ this.drawRasterBakeQuad(
326
+ this.demNoDataUnderlay(slot.noDataColor),
327
+ true,
328
+ [0, 0],
329
+ [1, 1],
330
+ );
331
+ }
332
+
333
+ // Coarse-first painter's order: a shared ancestor spans every finer
334
+ // source's rect, so it must not be drawn over them.
335
+ const sources = [...slot.sources].sort(
336
+ (a, b) => a.uvScale[0] - b.uvScale[0],
337
+ );
338
+ for (const source of sources) {
339
+ this.drawRasterBakeQuad(
340
+ source.texture,
341
+ slot.isElevationHeatmap,
342
+ source.uvOffset,
343
+ source.uvScale,
344
+ );
345
+ }
346
+ });
347
+ }
348
+
349
+ /**
350
+ * Shared bake loop over per-layer drape render targets (slot `i` →
351
+ * `renderTargets[i]`): saves/restores the renderer state, clears each live
352
+ * target once and lets `drawSlot` accumulate that slot's sources into it —
353
+ * autoClear stays off so the sources mosaic instead of overwriting one
354
+ * another.
355
+ */
356
+ private bakeSlotTargets<S>(
357
+ slots: readonly (S | undefined)[],
358
+ renderTargets: readonly (WebGLRenderTarget | undefined)[],
359
+ drawSlot: (slot: S) => void,
360
+ ): void {
208
361
  const prevTarget = this.renderer.getRenderTarget();
209
362
  const prevClear = this.renderer.getClearColor(PREV_CLEAR_COLOR);
210
363
  const prevClearAlpha = this.renderer.getClearAlpha();
@@ -220,35 +373,14 @@ export class TileTextureCompositor {
220
373
  // An empty slot only needs a clear when the target holds stale content
221
374
  // from a previous bake; a never-touched target has no GL storage yet
222
375
  // and must not be render-targeted (that would allocate it).
223
- if (!slot && !this.touchedVectorTargets.has(renderTarget)) continue;
224
- this.touchedVectorTargets.add(renderTarget);
376
+ if (!slot && !this.touchedTargets.has(renderTarget)) continue;
377
+ this.touchedTargets.add(renderTarget);
225
378
 
226
379
  this.renderer.setRenderTarget(renderTarget);
227
380
  this.renderer.setClearColor(0x000, 0);
228
381
  this.renderer.clear();
229
382
 
230
- if (slot) {
231
- for (const source of slot.sources) {
232
- const scene = this.texturizedScenes.findSceneByLayerId(
233
- source.tileHandle,
234
- slot.layerId,
235
- );
236
- if (!scene || scene.removed || !scene.children.length) continue;
237
- // Frame the terrain tile's sub-rect of this source tile from the
238
- // mercator uvOffset/uvScale: meshUv ∈ [0,1] → vectorUv = uvOffset +
239
- // meshUv·uvScale, source scene NDC = 2·vectorUv − 1. A source finer than
240
- // the terrain tile yields a camera wider than [-1, 1], so it draws into
241
- // only its sub-rect and leaves the rest of the RT transparent.
242
- const [ox, oy] = source.uvOffset;
243
- const [sx, sy] = source.uvScale;
244
- camera.left = 2 * ox - 1;
245
- camera.right = 2 * (ox + sx) - 1;
246
- camera.bottom = 2 * oy - 1;
247
- camera.top = 2 * (oy + sy) - 1;
248
- camera.updateProjectionMatrix();
249
- this.renderer.render(scene, camera);
250
- }
251
- }
383
+ if (slot) drawSlot(slot);
252
384
 
253
385
  renderTarget.texture.needsUpdate = true;
254
386
  }
@@ -258,6 +390,86 @@ export class TileTextureCompositor {
258
390
  this.renderer.setClearColor(prevClear, prevClearAlpha);
259
391
  }
260
392
 
393
+ /**
394
+ * Frame the shared bake camera on the terrain tile's sub-rect of a source
395
+ * from the mercator affine: meshUv ∈ [0,1] → sourceUv = uvOffset +
396
+ * meshUv·uvScale, source NDC = 2·sourceUv − 1. A source finer than the
397
+ * terrain tile yields a camera wider than [-1, 1], so it draws into only
398
+ * its sub-rect and leaves the rest of the render target transparent.
399
+ */
400
+ private frameBakeCamera(
401
+ uvOffset: [number, number],
402
+ uvScale: [number, number],
403
+ ): OrthographicCamera {
404
+ const [ox, oy] = uvOffset;
405
+ const [sx, sy] = uvScale;
406
+ const camera = this.bakeCamera;
407
+ camera.left = 2 * ox - 1;
408
+ camera.right = 2 * (ox + sx) - 1;
409
+ camera.bottom = 2 * oy - 1;
410
+ camera.top = 2 * (oy + sy) - 1;
411
+ camera.updateProjectionMatrix();
412
+ return camera;
413
+ }
414
+
415
+ /** Draw one textured quad into the current render target through the bake
416
+ * camera window — the value-preserving copy described on
417
+ * {@link renderRasterTiles}. */
418
+ private drawRasterBakeQuad(
419
+ texture: Texture,
420
+ isElevationHeatmap: boolean,
421
+ uvOffset: [number, number],
422
+ uvScale: [number, number],
423
+ ): void {
424
+ const material = this.rasterBakeMaterial;
425
+ this.configureRasterBakeSource(texture, isElevationHeatmap);
426
+ // First map assignment flips USE_MAP; later swaps are uniform-only.
427
+ if (material.map === null) material.needsUpdate = true;
428
+ material.map = texture;
429
+ this.renderer.render(
430
+ this.rasterBakeScene,
431
+ this.frameBakeCamera(uvOffset, uvScale),
432
+ );
433
+ }
434
+
435
+ /** The cached 1×1 no-data texture, (re)created when the color changes. */
436
+ private demNoDataUnderlay(color: [number, number, number]): DataTexture {
437
+ const key = color.join(",");
438
+ if (!this.demNoDataTexture || this.demNoDataTextureKey !== key) {
439
+ this.demNoDataTexture?.dispose();
440
+ const texture = new DataTexture(
441
+ new Uint8Array([...color, 255]),
442
+ 1,
443
+ 1,
444
+ RGBAFormat,
445
+ UnsignedByteType,
446
+ );
447
+ texture.needsUpdate = true;
448
+ this.demNoDataTexture = texture;
449
+ this.demNoDataTextureKey = key;
450
+ }
451
+ return this.demNoDataTexture;
452
+ }
453
+
454
+ /** Value-preserving sampler settings for a bake source texture (see
455
+ * {@link renderRasterTiles}); idempotent so re-bakes don't re-upload. */
456
+ private configureRasterBakeSource(tex: Texture, isElevationHeatmap: boolean) {
457
+ const colorSpace = isElevationHeatmap ? NoColorSpace : SRGBColorSpace;
458
+ const filter = isElevationHeatmap ? NearestFilter : LinearFilter;
459
+ if (
460
+ tex.colorSpace !== colorSpace ||
461
+ tex.minFilter !== filter ||
462
+ tex.magFilter !== filter ||
463
+ tex.generateMipmaps
464
+ ) {
465
+ tex.colorSpace = colorSpace;
466
+ tex.minFilter = filter;
467
+ tex.magFilter = filter;
468
+ tex.generateMipmaps = false;
469
+ tex.needsUpdate = true;
470
+ }
471
+ }
472
+
261
473
  // ---------------------------------------------------------------------
262
474
  // MRT composite pass
263
475
  // ---------------------------------------------------------------------
@@ -354,7 +566,7 @@ export class TileTextureCompositor {
354
566
  // uniform ownership: each active enhancer attaches its own uniform refs and
355
567
  // contributes the matching GLSL, so a new expression touches only its module.
356
568
  const chain = createCompositeLayerEnhancers(features);
357
- const core = createCoreUniformMutates();
569
+ const core = createCoreUniformMutates(this.mercatorY);
358
570
 
359
571
  const uniforms: CompositeUniformTarget = {};
360
572
  core.attachUniforms(uniforms, numTextures, placeholderTexture);
@@ -416,6 +628,10 @@ export class TileTextureCompositor {
416
628
  m.placeholderTexture.dispose();
417
629
  }
418
630
  this.materialCache.clear();
631
+ // Also frees the raster-bake quad, which shares this geometry.
419
632
  this.quadMesh.geometry.dispose();
633
+ this.rasterBakeMaterial.dispose();
634
+ this.demNoDataTexture?.dispose();
635
+ this.demNoDataTexture = null;
420
636
  }
421
637
  }
@@ -234,7 +234,13 @@ float interpolateDEMHeights(float h00, float h10, float h01, float h11, vec2 fra
234
234
 
235
235
 
236
236
 
237
- float sampleElevationBilinear(sampler2D demTexture, vec2 uv, ivec2 texSize) {
237
+
238
+
239
+
240
+
241
+
242
+
243
+ float sampleElevationBilinear(sampler2D demTexture, vec2 uv, ivec2 texSize, out bool valid) {
238
244
 
239
245
  DEMBilinearData data = prepareDEMBilinear(texSize, uv);
240
246
 
@@ -251,7 +257,8 @@ float interpolateDEMHeights(float h00, float h10, float h01, float h11, vec2 fra
251
257
  h = interpolateDEMHeights(h00, h10, h01, h11, data.frac);
252
258
  }
253
259
 
254
- if (!isValidHeight(h)) {
260
+ valid = isValidHeight(h);
261
+ if (!valid) {
255
262
  return 0.0;
256
263
  }
257
264
 
@@ -329,8 +336,9 @@ void main() {
329
336
  vec4 texColor0;
330
337
  if (uIsElevationHeatmaps[0]) {
331
338
  ivec2 demTexSize = textureSize(uTextures[0], 0);
332
- float normalized_h = sampleElevationBilinear(uTextures[0], texUv0, demTexSize);
333
- texColor0 = vec4(texture2D(uColorMapTexture, vec2(normalized_h, 0.5)).rgb, 1.0);
339
+ bool demValid0;
340
+ float normalized_h = sampleElevationBilinear(uTextures[0], texUv0, demTexSize, demValid0);
341
+ texColor0 = vec4(texture2D(uColorMapTexture, vec2(normalized_h, 0.5)).rgb, demValid0 ? 1.0 : 0.0);
334
342
  } else {
335
343
  texColor0 = texture2D(uTextures[0], texUv0) * vec4(uColors[0], 1.0);
336
344
  }
@@ -397,8 +405,9 @@ void main() {
397
405
  vec4 texColor1;
398
406
  if (uIsElevationHeatmaps[1]) {
399
407
  ivec2 demTexSize = textureSize(uTextures[1], 0);
400
- float normalized_h = sampleElevationBilinear(uTextures[1], texUv1, demTexSize);
401
- texColor1 = vec4(texture2D(uColorMapTexture, vec2(normalized_h, 0.5)).rgb, 1.0);
408
+ bool demValid1;
409
+ float normalized_h = sampleElevationBilinear(uTextures[1], texUv1, demTexSize, demValid1);
410
+ texColor1 = vec4(texture2D(uColorMapTexture, vec2(normalized_h, 0.5)).rgb, demValid1 ? 1.0 : 0.0);
402
411
  } else {
403
412
  texColor1 = texture2D(uTextures[1], texUv1) * vec4(uColors[1], 1.0);
404
413
  }
@@ -465,8 +474,9 @@ void main() {
465
474
  vec4 texColor2;
466
475
  if (uIsElevationHeatmaps[2]) {
467
476
  ivec2 demTexSize = textureSize(uTextures[2], 0);
468
- float normalized_h = sampleElevationBilinear(uTextures[2], texUv2, demTexSize);
469
- texColor2 = vec4(texture2D(uColorMapTexture, vec2(normalized_h, 0.5)).rgb, 1.0);
477
+ bool demValid2;
478
+ float normalized_h = sampleElevationBilinear(uTextures[2], texUv2, demTexSize, demValid2);
479
+ texColor2 = vec4(texture2D(uColorMapTexture, vec2(normalized_h, 0.5)).rgb, demValid2 ? 1.0 : 0.0);
470
480
  } else {
471
481
  texColor2 = texture2D(uTextures[2], texUv2) * vec4(uColors[2], 1.0);
472
482
  }
@@ -533,8 +543,9 @@ void main() {
533
543
  vec4 texColor3;
534
544
  if (uIsElevationHeatmaps[3]) {
535
545
  ivec2 demTexSize = textureSize(uTextures[3], 0);
536
- float normalized_h = sampleElevationBilinear(uTextures[3], texUv3, demTexSize);
537
- texColor3 = vec4(texture2D(uColorMapTexture, vec2(normalized_h, 0.5)).rgb, 1.0);
546
+ bool demValid3;
547
+ float normalized_h = sampleElevationBilinear(uTextures[3], texUv3, demTexSize, demValid3);
548
+ texColor3 = vec4(texture2D(uColorMapTexture, vec2(normalized_h, 0.5)).rgb, demValid3 ? 1.0 : 0.0);
538
549
  } else {
539
550
  texColor3 = texture2D(uTextures[3], texUv3) * vec4(uColors[3], 1.0);
540
551
  }
@@ -857,7 +868,13 @@ float interpolateDEMHeights(float h00, float h10, float h01, float h11, vec2 fra
857
868
 
858
869
 
859
870
 
860
- float sampleElevationBilinear(sampler2D demTexture, vec2 uv, ivec2 texSize) {
871
+
872
+
873
+
874
+
875
+
876
+
877
+ float sampleElevationBilinear(sampler2D demTexture, vec2 uv, ivec2 texSize, out bool valid) {
861
878
 
862
879
  DEMBilinearData data = prepareDEMBilinear(texSize, uv);
863
880
 
@@ -874,7 +891,8 @@ float interpolateDEMHeights(float h00, float h10, float h01, float h11, vec2 fra
874
891
  h = interpolateDEMHeights(h00, h10, h01, h11, data.frac);
875
892
  }
876
893
 
877
- if (!isValidHeight(h)) {
894
+ valid = isValidHeight(h);
895
+ if (!valid) {
878
896
  return 0.0;
879
897
  }
880
898
 
@@ -952,8 +970,9 @@ void main() {
952
970
  vec4 texColor0;
953
971
  if (uIsElevationHeatmaps[0]) {
954
972
  ivec2 demTexSize = textureSize(uTextures[0], 0);
955
- float normalized_h = sampleElevationBilinear(uTextures[0], texUv0, demTexSize);
956
- texColor0 = vec4(texture2D(uColorMapTexture, vec2(normalized_h, 0.5)).rgb, 1.0);
973
+ bool demValid0;
974
+ float normalized_h = sampleElevationBilinear(uTextures[0], texUv0, demTexSize, demValid0);
975
+ texColor0 = vec4(texture2D(uColorMapTexture, vec2(normalized_h, 0.5)).rgb, demValid0 ? 1.0 : 0.0);
957
976
  } else {
958
977
  texColor0 = texture2D(uTextures[0], texUv0) * vec4(uColors[0], 1.0);
959
978
  }
@@ -1016,8 +1035,9 @@ void main() {
1016
1035
  vec4 texColor1;
1017
1036
  if (uIsElevationHeatmaps[1]) {
1018
1037
  ivec2 demTexSize = textureSize(uTextures[1], 0);
1019
- float normalized_h = sampleElevationBilinear(uTextures[1], texUv1, demTexSize);
1020
- texColor1 = vec4(texture2D(uColorMapTexture, vec2(normalized_h, 0.5)).rgb, 1.0);
1038
+ bool demValid1;
1039
+ float normalized_h = sampleElevationBilinear(uTextures[1], texUv1, demTexSize, demValid1);
1040
+ texColor1 = vec4(texture2D(uColorMapTexture, vec2(normalized_h, 0.5)).rgb, demValid1 ? 1.0 : 0.0);
1021
1041
  } else {
1022
1042
  texColor1 = texture2D(uTextures[1], texUv1) * vec4(uColors[1], 1.0);
1023
1043
  }
@@ -16,6 +16,7 @@ export type DirtyReason =
16
16
  | "material"
17
17
  | "texture-binding"
18
18
  | "vector-revision"
19
+ | "raster-revision"
19
20
  | "hillshade";
20
21
 
21
22
  /**
@@ -121,6 +122,12 @@ export type HillshadeCompositeLayer = CompositeLayerBase & {
121
122
  export type ElevationHeatmapCompositeLayer = CompositeLayerBase & {
122
123
  kind: "elevationHeatmap";
123
124
  opacity: number;
125
+ /**
126
+ * Same Mercator latitude reprojection band as {@link RasterCompositeLayer}:
127
+ * the heatmap decode samples the reprojected per-slot UV, so a DEM draped on
128
+ * Geographic terrain (baked or direct) remaps identically to color imagery.
129
+ */
130
+ reproject?: [number, number];
124
131
  };
125
132
 
126
133
  export type CompositeLayer =
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { demNoDataColorBytes } from "./demNoDataColor";
4
+
5
+ describe("demNoDataColorBytes", () => {
6
+ it("solves the Japan GSI boundary (2^23) to (128, 0, 0)", () => {
7
+ expect(demNoDataColorBytes({ x: 65536, y: 256, z: 1 }, 8388608)).toEqual([
8
+ 128, 0, 0,
9
+ ]);
10
+ });
11
+
12
+ it("solves the Mapbox boundary (10000) to (0, 39, 16)", () => {
13
+ expect(demNoDataColorBytes({ x: 65536, y: 256, z: 1 }, 10000)).toEqual([
14
+ 0, 39, 16,
15
+ ]);
16
+ });
17
+
18
+ it("solves the Terrarium boundary (0) to black", () => {
19
+ expect(demNoDataColorBytes({ x: 256, y: 1, z: 1 / 256 }, 0)).toEqual([
20
+ 0, 0, 0,
21
+ ]);
22
+ });
23
+
24
+ it("keeps channel order independent of scaler magnitude order", () => {
25
+ // Same positional system with permuted channels: G is the coarse digit.
26
+ expect(demNoDataColorBytes({ x: 256, y: 65536, z: 1 }, 8388608)).toEqual([
27
+ 0, 128, 0,
28
+ ]);
29
+ });
30
+
31
+ it("accepts a non-exact color inside the shader's no-data band", () => {
32
+ // 0.75 has no exact byte decomposition with unit scalers, but black
33
+ // decodes to 0 and |0 - 0.75| <= 1, so the shader still flags it no-data.
34
+ expect(demNoDataColorBytes({ x: 1, y: 1, z: 1 }, 0.75)).toEqual([0, 0, 0]);
35
+ // Band edge: the closest representable value is exactly 1 away
36
+ // ((1,0,0) decodes to 2, |2 - 3| == 1), which the shader's
37
+ // `abs(x - boundary) <= 1` check still classifies as no-data.
38
+ expect(demNoDataColorBytes({ x: 2, y: 0, z: 0 }, 3)).toEqual([1, 0, 0]);
39
+ });
40
+
41
+ it("returns null when no byte color decodes inside the no-data band", () => {
42
+ // Boundary beyond the encodable range (max dot product is 765).
43
+ expect(demNoDataColorBytes({ x: 1, y: 1, z: 1 }, 100000)).toBeNull();
44
+ // Coarse scalers leave the nearest representable value a full step away.
45
+ expect(demNoDataColorBytes({ x: 4, y: 0, z: 0 }, 6)).toBeNull();
46
+ });
47
+ });