@navaramap/three 0.0.0 → 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/fontWorker-CCWL5pbx.js +2 -0
- package/dist/assets/fontWorker-CCWL5pbx.js.map +1 -0
- package/dist/assets/index-BdtHOOT9.js +12 -0
- package/dist/assets/index-BdtHOOT9.js.map +1 -0
- package/dist/assets/{navara_wasm_font_worker_bg-665OLxjy.wasm → navara_wasm_font_worker_bg-Bnck8OGf.wasm} +0 -0
- package/dist/assets/{navara_wasm_worker_bg-BXhKC5bE.wasm → navara_wasm_worker_bg-DG9ORAmj.wasm} +0 -0
- package/dist/index.d.ts +142 -51
- package/dist/index.js +4772 -4510
- package/dist/index.js.map +1 -1
- package/dist/index.umd.cjs +226 -218
- package/dist/index.umd.cjs.map +1 -1
- package/package.json +13 -13
- package/src/event/context.ts +5 -0
- package/src/index.ts +10 -2
- package/src/material/enhancer/tileComposite/tileCompositeBaseEnhancer/mutates.ts +6 -1
- package/src/material/enhancer/tileComposite/tileElevationHeatmapEnhancer/shader.ts +9 -2
- package/src/mesh/tile/drapeResolver.ts +76 -0
- package/src/mesh/{tile.test.ts → tile/index.test.ts} +16 -6
- package/src/mesh/{tile.ts → tile/index.ts} +124 -392
- package/src/mesh/tile/rasterDrapeResolver.ts +423 -0
- package/src/mesh/tile/vectorDrapeResolver.ts +404 -0
- package/src/tileTexture/TileTextureCache.test.ts +2 -0
- package/src/tileTexture/TileTextureCache.ts +1 -0
- package/src/tileTexture/TileTextureCompositor.test.ts +105 -0
- package/src/tileTexture/TileTextureCompositor.ts +241 -31
- package/src/tileTexture/__snapshots__/CompositeShader.snapshot.test.ts.snap +36 -16
- package/src/tileTexture/types.ts +7 -0
- package/src/utils/demNoDataColor.test.ts +47 -0
- package/src/utils/demNoDataColor.ts +48 -0
- package/dist/assets/fontWorker-CEJEfRpb.js +0 -2
- package/dist/assets/fontWorker-CEJEfRpb.js.map +0 -1
- package/dist/assets/index-BUB-ijMO.js +0 -12
- package/dist/assets/index-BUB-ijMO.js.map +0 -1
|
@@ -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,
|
|
@@ -89,6 +95,30 @@ type CachedMaterial = {
|
|
|
89
95
|
placeholderTexture: Texture;
|
|
90
96
|
};
|
|
91
97
|
|
|
98
|
+
/** One WebMercator raster tile texture to bake into a layer's render target,
|
|
99
|
+
* framed by the Rust-resolved mercator affine (terrain `[0,1]` UV → source UV). */
|
|
100
|
+
export type RasterBakeSource = {
|
|
101
|
+
texture: Texture;
|
|
102
|
+
uvOffset: [number, number];
|
|
103
|
+
uvScale: [number, number];
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** One baked raster layer: its overlapping WM tile textures, mosaicked into one
|
|
107
|
+
* render target. Heatmap layers carry encoded DEM, copied raw (nearest, no
|
|
108
|
+
* color conversion) so the composite decode stays valid. */
|
|
109
|
+
export type RasterBakeSlot = {
|
|
110
|
+
isElevationHeatmap: boolean;
|
|
111
|
+
/**
|
|
112
|
+
* The decoder's no-data (boundary) color for heatmap slots: painted across
|
|
113
|
+
* the render target before the sources so uncovered regions decode as
|
|
114
|
+
* "no elevation" — the no-data story lives entirely in the encoded-elevation
|
|
115
|
+
* domain, keeping the alpha channel free for future RGBA encodings. Absent
|
|
116
|
+
* for color slots and for decoders with no byte-representable boundary.
|
|
117
|
+
*/
|
|
118
|
+
noDataColor?: [number, number, number];
|
|
119
|
+
sources: RasterBakeSource[];
|
|
120
|
+
};
|
|
121
|
+
|
|
92
122
|
/**
|
|
93
123
|
* Integration layer for per-tile texture composition.
|
|
94
124
|
*
|
|
@@ -112,9 +142,10 @@ export class TileTextureCompositor {
|
|
|
112
142
|
* per-tile GPU byte cost it reports to the memory ledger. */
|
|
113
143
|
readonly size: number;
|
|
114
144
|
private readonly texturizedScenes: TexturizedSceneByTileCoordinates;
|
|
115
|
-
// Single
|
|
116
|
-
// fallback is resolved in Rust,
|
|
117
|
-
|
|
145
|
+
// Single camera shared by every drape bake (vector and raster), re-framed
|
|
146
|
+
// per source by `frameBakeCamera`. Ancestor fallback is resolved in Rust,
|
|
147
|
+
// so no per-tile camera transform is needed.
|
|
148
|
+
private readonly bakeCamera = new OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
|
118
149
|
|
|
119
150
|
// Composite-pass machinery (lazily created on first use so tests that
|
|
120
151
|
// don't render don't pay the allocation).
|
|
@@ -122,11 +153,38 @@ export class TileTextureCompositor {
|
|
|
122
153
|
private readonly quadCamera = new OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
|
123
154
|
private readonly quadMesh: Mesh;
|
|
124
155
|
private readonly materialCache = new Map<string, CachedMaterial>();
|
|
125
|
-
//
|
|
156
|
+
// Drape render targets this compositor has ever rendered to. three.js
|
|
126
157
|
// allocates a render target's GL framebuffer on first setRenderTarget, so
|
|
127
158
|
// an empty slot whose target was never touched must be skipped entirely —
|
|
128
159
|
// clearing it would allocate size² × 4 bytes of GPU memory for nothing.
|
|
129
|
-
private readonly
|
|
160
|
+
private readonly touchedTargets = new WeakSet<WebGLRenderTarget>();
|
|
161
|
+
|
|
162
|
+
// Raster-bake machinery (lazily used): a textured quad drawn once per source
|
|
163
|
+
// through the same windowing camera as the vector bake. `toneMapped: false` +
|
|
164
|
+
// per-kind texture color spaces keep the bake a value-preserving copy.
|
|
165
|
+
//
|
|
166
|
+
// `transparent: true` + `blending: NoBlending` carries the source ALPHA into
|
|
167
|
+
// the render target: an opaque material (transparent=false + NormalBlending)
|
|
168
|
+
// gets three.js's `OPAQUE` define, whose shader forces `diffuseColor.a = 1.0`
|
|
169
|
+
// and destroys the tile's transparency. NoBlending keeps replace semantics —
|
|
170
|
+
// sources are disjoint WM tiles (plus a coarse-first ancestor underlay), so
|
|
171
|
+
// each draw must overwrite, not blend, and straight (r, g, b, a) texels land
|
|
172
|
+
// in the target for the composite's own alpha blend.
|
|
173
|
+
private readonly rasterBakeScene = new Scene();
|
|
174
|
+
private readonly rasterBakeMaterial = new MeshBasicMaterial({
|
|
175
|
+
toneMapped: false,
|
|
176
|
+
depthTest: false,
|
|
177
|
+
depthWrite: false,
|
|
178
|
+
transparent: true,
|
|
179
|
+
blending: NoBlending,
|
|
180
|
+
});
|
|
181
|
+
// 1×1 no-data underlay for baked heatmap targets, drawn through the same
|
|
182
|
+
// value-preserving bake pipeline as the sources — a texture upload is
|
|
183
|
+
// byte-exact, whereas a clear color would pass through the renderer's color
|
|
184
|
+
// management and could land one byte off (fatal for positional encodings,
|
|
185
|
+
// where the high byte weighs 65536 in decoded x). Cached per color.
|
|
186
|
+
private demNoDataTexture: DataTexture | null = null;
|
|
187
|
+
private demNoDataTextureKey = "";
|
|
130
188
|
|
|
131
189
|
constructor(opts: TileTextureCompositorOptions) {
|
|
132
190
|
this.renderer = opts.renderer;
|
|
@@ -141,7 +199,15 @@ export class TileTextureCompositor {
|
|
|
141
199
|
this.quadMesh = new Mesh(new PlaneGeometry(2, 2));
|
|
142
200
|
this.quadScene.add(this.quadMesh);
|
|
143
201
|
this.quadCamera.position.z = 1;
|
|
144
|
-
this.
|
|
202
|
+
this.bakeCamera.position.z = 1;
|
|
203
|
+
|
|
204
|
+
// Raster-bake quad: spans the source tile's NDC frame like a vector scene,
|
|
205
|
+
// so the same windowing camera math frames it. Shares the fullscreen quad
|
|
206
|
+
// geometry with `quadMesh` (both are static [-1, 1] planes; the windowing
|
|
207
|
+
// lives in the camera), so dispose() frees one geometry for both.
|
|
208
|
+
this.rasterBakeScene.add(
|
|
209
|
+
new Mesh(this.quadMesh.geometry, this.rasterBakeMaterial),
|
|
210
|
+
);
|
|
145
211
|
}
|
|
146
212
|
|
|
147
213
|
// ---------------------------------------------------------------------
|
|
@@ -204,7 +270,88 @@ export class TileTextureCompositor {
|
|
|
204
270
|
}[],
|
|
205
271
|
renderTargets: WebGLRenderTarget[],
|
|
206
272
|
): void {
|
|
207
|
-
|
|
273
|
+
this.bakeSlotTargets(slots, renderTargets, (slot) => {
|
|
274
|
+
for (const source of slot.sources) {
|
|
275
|
+
const scene = this.texturizedScenes.findSceneByLayerId(
|
|
276
|
+
source.tileHandle,
|
|
277
|
+
slot.layerId,
|
|
278
|
+
);
|
|
279
|
+
if (!scene || scene.removed || !scene.children.length) continue;
|
|
280
|
+
this.renderer.render(
|
|
281
|
+
scene,
|
|
282
|
+
this.frameBakeCamera(source.uvOffset, source.uvScale),
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Bake each raster layer's overlapping WM tile textures into ONE render
|
|
290
|
+
* target (slot `i` → `renderTargets[i]`), mirroring {@link renderVectorScenes}:
|
|
291
|
+
* every source is a textured quad framed by the Rust-supplied mercator affine
|
|
292
|
+
* `uvOffset`/`uvScale`, so the render target ends up spanning the terrain
|
|
293
|
+
* tile's (Mercator-projected) extent and the composite treats the whole layer
|
|
294
|
+
* as one slot with the terrain-band reprojection — instead of one composite
|
|
295
|
+
* slot per overlapping tile, which overflowed the GPU slot budget on
|
|
296
|
+
* Geographic terrain once 3+ layers were draped.
|
|
297
|
+
*
|
|
298
|
+
* Sources are drawn coarse-first (ascending `uvScale`): an ancestor fallback
|
|
299
|
+
* covers the whole terrain rect, so painting it first lets the finer tiles
|
|
300
|
+
* overwrite it instead of being buried under coarse texels. The bake is a
|
|
301
|
+
* value-preserving copy (`toneMapped: false`; per-kind color spaces below), so
|
|
302
|
+
* heatmap DEM texels survive it bit-faithfully:
|
|
303
|
+
* - color imagery: source + target declared sRGB — decode at fetch, encode at
|
|
304
|
+
* write, decode again at the composite fetch: the same linear values the
|
|
305
|
+
* direct (non-baked) path produced.
|
|
306
|
+
* - elevation heatmap: everything `NoColorSpace` + nearest — raw texels, no
|
|
307
|
+
* interpolation, so the composite's decode-then-interpolate keeps working.
|
|
308
|
+
*/
|
|
309
|
+
renderRasterTiles(
|
|
310
|
+
slots: (RasterBakeSlot | undefined)[],
|
|
311
|
+
renderTargets: (WebGLRenderTarget | undefined)[],
|
|
312
|
+
): void {
|
|
313
|
+
this.bakeSlotTargets(slots, renderTargets, (slot) => {
|
|
314
|
+
// Heatmap targets: paint the decoder's no-data color across the whole
|
|
315
|
+
// target first, so regions no source covers decode as "no elevation"
|
|
316
|
+
// (the composite renders them transparent) instead of decoding the
|
|
317
|
+
// cleared black as a height.
|
|
318
|
+
if (slot.isElevationHeatmap && slot.noDataColor) {
|
|
319
|
+
this.drawRasterBakeQuad(
|
|
320
|
+
this.demNoDataUnderlay(slot.noDataColor),
|
|
321
|
+
true,
|
|
322
|
+
[0, 0],
|
|
323
|
+
[1, 1],
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Coarse-first painter's order: a shared ancestor spans every finer
|
|
328
|
+
// source's rect, so it must not be drawn over them.
|
|
329
|
+
const sources = [...slot.sources].sort(
|
|
330
|
+
(a, b) => a.uvScale[0] - b.uvScale[0],
|
|
331
|
+
);
|
|
332
|
+
for (const source of sources) {
|
|
333
|
+
this.drawRasterBakeQuad(
|
|
334
|
+
source.texture,
|
|
335
|
+
slot.isElevationHeatmap,
|
|
336
|
+
source.uvOffset,
|
|
337
|
+
source.uvScale,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Shared bake loop over per-layer drape render targets (slot `i` →
|
|
345
|
+
* `renderTargets[i]`): saves/restores the renderer state, clears each live
|
|
346
|
+
* target once and lets `drawSlot` accumulate that slot's sources into it —
|
|
347
|
+
* autoClear stays off so the sources mosaic instead of overwriting one
|
|
348
|
+
* another.
|
|
349
|
+
*/
|
|
350
|
+
private bakeSlotTargets<S>(
|
|
351
|
+
slots: readonly (S | undefined)[],
|
|
352
|
+
renderTargets: readonly (WebGLRenderTarget | undefined)[],
|
|
353
|
+
drawSlot: (slot: S) => void,
|
|
354
|
+
): void {
|
|
208
355
|
const prevTarget = this.renderer.getRenderTarget();
|
|
209
356
|
const prevClear = this.renderer.getClearColor(PREV_CLEAR_COLOR);
|
|
210
357
|
const prevClearAlpha = this.renderer.getClearAlpha();
|
|
@@ -220,35 +367,14 @@ export class TileTextureCompositor {
|
|
|
220
367
|
// An empty slot only needs a clear when the target holds stale content
|
|
221
368
|
// from a previous bake; a never-touched target has no GL storage yet
|
|
222
369
|
// and must not be render-targeted (that would allocate it).
|
|
223
|
-
if (!slot && !this.
|
|
224
|
-
this.
|
|
370
|
+
if (!slot && !this.touchedTargets.has(renderTarget)) continue;
|
|
371
|
+
this.touchedTargets.add(renderTarget);
|
|
225
372
|
|
|
226
373
|
this.renderer.setRenderTarget(renderTarget);
|
|
227
374
|
this.renderer.setClearColor(0x000, 0);
|
|
228
375
|
this.renderer.clear();
|
|
229
376
|
|
|
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
|
-
}
|
|
377
|
+
if (slot) drawSlot(slot);
|
|
252
378
|
|
|
253
379
|
renderTarget.texture.needsUpdate = true;
|
|
254
380
|
}
|
|
@@ -258,6 +384,86 @@ export class TileTextureCompositor {
|
|
|
258
384
|
this.renderer.setClearColor(prevClear, prevClearAlpha);
|
|
259
385
|
}
|
|
260
386
|
|
|
387
|
+
/**
|
|
388
|
+
* Frame the shared bake camera on the terrain tile's sub-rect of a source
|
|
389
|
+
* from the mercator affine: meshUv ∈ [0,1] → sourceUv = uvOffset +
|
|
390
|
+
* meshUv·uvScale, source NDC = 2·sourceUv − 1. A source finer than the
|
|
391
|
+
* terrain tile yields a camera wider than [-1, 1], so it draws into only
|
|
392
|
+
* its sub-rect and leaves the rest of the render target transparent.
|
|
393
|
+
*/
|
|
394
|
+
private frameBakeCamera(
|
|
395
|
+
uvOffset: [number, number],
|
|
396
|
+
uvScale: [number, number],
|
|
397
|
+
): OrthographicCamera {
|
|
398
|
+
const [ox, oy] = uvOffset;
|
|
399
|
+
const [sx, sy] = uvScale;
|
|
400
|
+
const camera = this.bakeCamera;
|
|
401
|
+
camera.left = 2 * ox - 1;
|
|
402
|
+
camera.right = 2 * (ox + sx) - 1;
|
|
403
|
+
camera.bottom = 2 * oy - 1;
|
|
404
|
+
camera.top = 2 * (oy + sy) - 1;
|
|
405
|
+
camera.updateProjectionMatrix();
|
|
406
|
+
return camera;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Draw one textured quad into the current render target through the bake
|
|
410
|
+
* camera window — the value-preserving copy described on
|
|
411
|
+
* {@link renderRasterTiles}. */
|
|
412
|
+
private drawRasterBakeQuad(
|
|
413
|
+
texture: Texture,
|
|
414
|
+
isElevationHeatmap: boolean,
|
|
415
|
+
uvOffset: [number, number],
|
|
416
|
+
uvScale: [number, number],
|
|
417
|
+
): void {
|
|
418
|
+
const material = this.rasterBakeMaterial;
|
|
419
|
+
this.configureRasterBakeSource(texture, isElevationHeatmap);
|
|
420
|
+
// First map assignment flips USE_MAP; later swaps are uniform-only.
|
|
421
|
+
if (material.map === null) material.needsUpdate = true;
|
|
422
|
+
material.map = texture;
|
|
423
|
+
this.renderer.render(
|
|
424
|
+
this.rasterBakeScene,
|
|
425
|
+
this.frameBakeCamera(uvOffset, uvScale),
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** The cached 1×1 no-data texture, (re)created when the color changes. */
|
|
430
|
+
private demNoDataUnderlay(color: [number, number, number]): DataTexture {
|
|
431
|
+
const key = color.join(",");
|
|
432
|
+
if (!this.demNoDataTexture || this.demNoDataTextureKey !== key) {
|
|
433
|
+
this.demNoDataTexture?.dispose();
|
|
434
|
+
const texture = new DataTexture(
|
|
435
|
+
new Uint8Array([...color, 255]),
|
|
436
|
+
1,
|
|
437
|
+
1,
|
|
438
|
+
RGBAFormat,
|
|
439
|
+
UnsignedByteType,
|
|
440
|
+
);
|
|
441
|
+
texture.needsUpdate = true;
|
|
442
|
+
this.demNoDataTexture = texture;
|
|
443
|
+
this.demNoDataTextureKey = key;
|
|
444
|
+
}
|
|
445
|
+
return this.demNoDataTexture;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Value-preserving sampler settings for a bake source texture (see
|
|
449
|
+
* {@link renderRasterTiles}); idempotent so re-bakes don't re-upload. */
|
|
450
|
+
private configureRasterBakeSource(tex: Texture, isElevationHeatmap: boolean) {
|
|
451
|
+
const colorSpace = isElevationHeatmap ? NoColorSpace : SRGBColorSpace;
|
|
452
|
+
const filter = isElevationHeatmap ? NearestFilter : LinearFilter;
|
|
453
|
+
if (
|
|
454
|
+
tex.colorSpace !== colorSpace ||
|
|
455
|
+
tex.minFilter !== filter ||
|
|
456
|
+
tex.magFilter !== filter ||
|
|
457
|
+
tex.generateMipmaps
|
|
458
|
+
) {
|
|
459
|
+
tex.colorSpace = colorSpace;
|
|
460
|
+
tex.minFilter = filter;
|
|
461
|
+
tex.magFilter = filter;
|
|
462
|
+
tex.generateMipmaps = false;
|
|
463
|
+
tex.needsUpdate = true;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
261
467
|
// ---------------------------------------------------------------------
|
|
262
468
|
// MRT composite pass
|
|
263
469
|
// ---------------------------------------------------------------------
|
|
@@ -416,6 +622,10 @@ export class TileTextureCompositor {
|
|
|
416
622
|
m.placeholderTexture.dispose();
|
|
417
623
|
}
|
|
418
624
|
this.materialCache.clear();
|
|
625
|
+
// Also frees the raster-bake quad, which shares this geometry.
|
|
419
626
|
this.quadMesh.geometry.dispose();
|
|
627
|
+
this.rasterBakeMaterial.dispose();
|
|
628
|
+
this.demNoDataTexture?.dispose();
|
|
629
|
+
this.demNoDataTexture = null;
|
|
420
630
|
}
|
|
421
631
|
}
|
|
@@ -234,7 +234,13 @@ float interpolateDEMHeights(float h00, float h10, float h01, float h11, vec2 fra
|
|
|
234
234
|
|
|
235
235
|
|
|
236
236
|
|
|
237
|
-
|
|
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
|
-
|
|
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
|
-
|
|
333
|
-
|
|
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
|
-
|
|
401
|
-
|
|
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
|
-
|
|
469
|
-
|
|
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
|
-
|
|
537
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
956
|
-
|
|
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
|
-
|
|
1020
|
-
|
|
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
|
}
|
package/src/tileTexture/types.ts
CHANGED
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Solve `dot(rgb, scaler) == boundary` for RGB bytes: the color a DEM texel
|
|
3
|
+
* must hold so the shader's `decodeDEMHeight` classifies it as no-data. The
|
|
4
|
+
* raster bake paints a baked heatmap render target with this color before
|
|
5
|
+
* drawing sources, so uncovered regions decode as "no elevation here" —
|
|
6
|
+
* entirely inside the encoded-elevation domain, leaving the alpha channel
|
|
7
|
+
* untouched (it is not part of any encoding and stays reserved for future
|
|
8
|
+
* RGBA ones).
|
|
9
|
+
*
|
|
10
|
+
* Solved by greedy positional decomposition (largest scaler first), which is
|
|
11
|
+
* exact for the positional presets (GSI, Mapbox, Terrarium); a non-exact
|
|
12
|
+
* decomposition is still accepted when it lands inside the shader's no-data
|
|
13
|
+
* tolerance band (`decodeDEMHeight` flags |x − boundary| <= 1). Returns null
|
|
14
|
+
* only when no byte color decodes within that band (an exotic custom decoder
|
|
15
|
+
* whose encoding cannot express no-data at all) — callers then skip the
|
|
16
|
+
* no-data underlay and a baked target's uncovered regions decode as whatever
|
|
17
|
+
* black decodes to, which the shader may colormap as a valid height.
|
|
18
|
+
*/
|
|
19
|
+
export function demNoDataColorBytes(
|
|
20
|
+
scaler: { x: number; y: number; z: number },
|
|
21
|
+
boundary: number,
|
|
22
|
+
): [number, number, number] | null {
|
|
23
|
+
const channels: { scaler: number; index: number }[] = [
|
|
24
|
+
{ scaler: scaler.x, index: 0 },
|
|
25
|
+
{ scaler: scaler.y, index: 1 },
|
|
26
|
+
{ scaler: scaler.z, index: 2 },
|
|
27
|
+
]
|
|
28
|
+
.filter((c) => c.scaler > 0)
|
|
29
|
+
.sort((a, b) => b.scaler - a.scaler);
|
|
30
|
+
|
|
31
|
+
const rgb: [number, number, number] = [0, 0, 0];
|
|
32
|
+
let rest = boundary;
|
|
33
|
+
for (const { scaler: s, index } of channels) {
|
|
34
|
+
// The epsilon guards float division just below an integer (e.g. 0.999…).
|
|
35
|
+
const v = Math.min(255, Math.max(0, Math.floor(rest / s + 1e-9)));
|
|
36
|
+
rgb[index] = v;
|
|
37
|
+
rest -= v * s;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The shader flags |x - boundary| <= 1 as no-data (dem_util.glsl,
|
|
41
|
+
// `epsilon_cmp`): accept exactly that band, boundary included — a color a
|
|
42
|
+
// full 1 away still classifies as no-data, so rejecting it would skip the
|
|
43
|
+
// underlay and let uncovered baked regions decode black as a valid height.
|
|
44
|
+
// No safety margin is subtracted: painting a band-edge color that float32
|
|
45
|
+
// drift pushes out of the band decodes as a near-boundary height, which is
|
|
46
|
+
// no worse than the black the skipped underlay would leave behind.
|
|
47
|
+
return Math.abs(rest) <= 1 ? rgb : null;
|
|
48
|
+
}
|