s2maps-gpu
Version:
S2 Maps GPU - An open source, high-performance, and GPU-accelerated map engine for rendering large-scale, interactive maps.
454 lines (453 loc) • 17.3 kB
JavaScript
import { project } from 'ui/camera/projector/mat4.js';
import { bboxST, llToTilePx, pointFromSTGL, pointMulScalar, pointNormalize, pointSub, } from 'gis-tools/index.js';
/**
* Create a new Tile given the approprate projection, context and ID.
* @param projection - the projection type (WM or S2)
* @param context - the GPU or WebGL context
* @param tileInfo - the tile identifier
* @returns the new Tile object
*/
export function createTile(projection, context, tileInfo) {
const Tile = projection === 'S2' ? S2Tile : WMTile;
return new Tile(context, tileInfo);
}
/** Base Tile Class that all Tiles inherit from. */
class Tile {
id;
face = 0;
i = 0;
j = 0;
zoom = 0;
division = 1;
tmpMaskID = 0;
mask;
bbox = [0, 0, 0, 0];
featureGuides = [];
context;
interactiveGuide = new Map();
uniforms = new Float32Array(7); // [isS2, face, zoom, sLow, tLow, deltaS, deltaT]
bottomTop = new Float32Array(8);
state = 'loading';
type = 'S2';
faceST;
matrix;
layersLoaded = new Set();
layersToBeLoaded;
// WM only feature: if the tile is "out of bounds", it references a real world tile
// by copying the parents featureGuides.
wrappedID;
dependents = [];
/**
* @param context - the GPU or WebGL context
* @param id - the tile ID
*/
constructor(context, id) {
this.context = context;
this.id = id;
}
/**
* inject references to featureGuide from each parentTile. Sometimes if we zoom really fast, we inject
* a parents' parent or deeper, so we need to reflect that in the tile property.
* @param parent - parent tile to inject
* @param layers - the effected layers to modify
*/
injectParentTile(parent, layers) {
// feature guides
for (const feature of parent.featureGuides) {
if (feature.maskLayer ?? false)
continue; // ignore mask features
const { maxzoom } = layers[feature.layerGuide.layerIndex];
const actualParent = feature.parent ?? parent;
if (this.zoom <= maxzoom) {
const bounds = this.#buildBounds(actualParent);
// @ts-expect-error - we need fix this one day
this.featureGuides.push(feature.duplicate(this, actualParent, bounds));
}
}
// interactive guides
for (const [id, interactive] of parent.interactiveGuide)
this.interactiveGuide.set(id, interactive);
}
/**
* inject references to featureGuide from a wrapped tile
* @param wrapped - the wrapped tile
*/
injectWrappedTile(wrapped) {
// add existing features to the wrapped tile
this.#addFeaturesToDependents(this, wrapped.featureGuides);
// let the wrapped tile know that it has a dependent
wrapped.dependents.push(this);
}
/**
* set the screen positions of the mask
* @param _ - the projector (not needed here)
*/
setScreenPositions(_) {
const { context, mask, bottomTop } = this;
// if WebGPU mask, we need to update the position buffer
if (mask.positionBuffer !== undefined) {
context.device?.queue.writeBuffer(mask.positionBuffer, 0, bottomTop);
}
}
/**
* get an interactive feature's properties if it exists
* @param id - the id of the feature
* @returns the interactive object
*/
getInteractiveFeature(id) {
return this.interactiveGuide.get(id);
}
/**
* add features to the tile
* @param features - the features to add
*/
addFeatures(features) {
const { featureGuides, layersLoaded } = this;
// filter parent tiles that were added
const layerIndexes = new Set(features.map((f) => f.layerGuide.layerIndex));
for (let i = featureGuides.length - 1; i >= 0; i--) {
const feature = featureGuides[i];
if (feature.parent !== undefined && layerIndexes.has(feature.layerGuide.layerIndex))
featureGuides.splice(i, 1);
}
// add features
this.featureGuides.push(...features);
// clear from sourceCheck then check if all sources are loaded
for (const layerIndex of layerIndexes)
layersLoaded.add(layerIndex);
// if this tile has dependents, we need to also add these features to those tiles
for (const dependent of this.dependents) {
this.#addFeaturesToDependents(dependent, features);
}
this.#checkState();
}
/**
* Flush message that was sent from the Source or Tile Workers letting this tile know the source and layer's state
* @param msg - input flush messge
*/
flush(msg) {
if (msg.from === 'source')
this.#sourceFlush({ ...msg });
else
this.#tileFlush({ ...msg });
for (const dependent of this.dependents)
dependent.flush(msg);
this.#checkState();
}
/** cleanup after itself. When a tile is deleted, it's adventageous to cleanup GPU cache. */
delete() {
this.state = 'deleted';
// remove all features
for (const feature of this.featureGuides)
feature.destroy?.();
// @ts-expect-error - we need to clear the array
this.featureGuides = [];
this.interactiveGuide = new Map();
// TODO: WebGPU needs the data past it's lifetime...
// IDEA: Copy the parent mask so that any data used is always isolated to the tile in question
// this.mask.destroy?.()
}
/* STYLE CHANGES */
/**
* Delete a layer
* @param index - the index of the layer
*/
deleteLayer(index) {
const { featureGuides } = this;
// remove any references to layerIndex
for (let i = featureGuides.length - 1; i >= 0; i--) {
const f = featureGuides[i];
if (f.layerGuide.layerIndex === index)
featureGuides.splice(i, 1);
}
// all layerIndexes greater than index should be decremented once
for (const { layerGuide } of this.featureGuides) {
if (layerGuide.layerIndex > index)
layerGuide.layerIndex--;
}
for (const dependent of this.dependents)
dependent.deleteLayer(index);
}
/**
* Reorder layers
* @param layerChanges - a map of layerIndex to new layerIndex
*/
reorderLayers(layerChanges) {
for (const { layerGuide } of this.featureGuides) {
const change = layerChanges[layerGuide.layerIndex];
if (change !== undefined)
layerGuide.layerIndex = change;
}
for (const dependent of this.dependents)
dependent.reorderLayers(layerChanges);
}
/**
* remove all sources that match the input sourceNames
* @param sourceNames - the names of the sources
*/
deleteSources(sourceNames) {
const { featureGuides } = this;
for (let i = featureGuides.length - 1; i >= 0; i--) {
const fg = featureGuides[i];
const fgSourceName = fg.layerGuide.sourceName.split(':')[0];
const keep = !sourceNames.includes(fgSourceName);
if (!keep) {
fg.destroy?.();
featureGuides.splice(i, 1);
}
}
for (const dependent of this.dependents)
dependent.deleteSources(sourceNames);
}
/* DATA */
/**
* Inject interactive data. we don't parse the interactiveData immediately to save time
* @param interactiveGuide - the interactive guide
* @param interactiveData - the interactive data
*/
injectInteractiveData(interactiveGuide, interactiveData) {
// setup variables
let id, start, end;
const textDecoder = new TextDecoder('utf-8');
// build interactive guide
for (let i = 0, gl = interactiveGuide.length; i < gl; i += 3) {
id = interactiveGuide[i];
start = interactiveGuide[i + 1];
end = interactiveGuide[i + 2];
// parse feature and add properties
const interactiveObject = JSON.parse(textDecoder.decode(interactiveData.slice(start, end)));
this.interactiveGuide.set(id, interactiveObject);
}
}
/* INTERNAL */
/**
* currently this is for glyphs, points, and heatmaps. By sharing glyph data with children,
* the glyphs will be rendered 4 or even more times. To alleviate this, we can set boundaries
* of what points will be considered
* @param parent - the parent tile
* @returns the bounds
*/
#buildBounds(parent) {
let { i, j, zoom } = this;
const parentZoom = parent.zoom;
// get the scale
const scale = 1 << (zoom - parentZoom);
// get i and j shift
let iShift = 0;
let jShift = 0;
while (zoom > parentZoom) {
const div = 1 << (zoom - parentZoom);
if (i % 2 !== 0)
iShift += 1 / div;
if (j % 2 !== 0)
jShift += 1 / div;
// decrement
i = i >> 1;
j = j >> 1;
zoom--;
}
// build the bounds bbox
return [0 + iShift, 0 + jShift, 1 / scale + iShift, 1 / scale + jShift];
}
/** Checks the state of the layers. Updates the tiles state if all layers are loaded */
#checkState() {
const { layersLoaded, layersToBeLoaded } = this;
if (this.state === 'deleted' || layersToBeLoaded === undefined)
return;
// if all layers are loaded, set state to loaded
if (setBContainsA(layersToBeLoaded, layersLoaded))
this.state = 'loaded';
}
/**
* Add features to list of dependents we need to update
* @param dependent - the dependent
* @param features - the features to add
*/
#addFeaturesToDependents(dependent, features) {
// @ts-expect-error - no reason this should be failing buit it is
const dFeatures = features
.filter((f) => f.parent === undefined)
// @ts-expect-error - no reason this should be failing buit it is
.map((f) => f.duplicate(dependent, f.parent, f.bounds));
dependent.addFeatures(dFeatures);
}
/**
* Flush the source data
* @param msg - the message
*/
#sourceFlush(msg) {
this.layersToBeLoaded = msg.layersToBeLoaded;
for (const dependent of this.dependents)
dependent.#sourceFlush(msg);
this.#checkState();
}
/**
* Flush the tile data
* @param msg - the message
*/
#tileFlush(msg) {
const { featureGuides, layersLoaded } = this;
const { deadLayers } = msg;
// otherwise remove "left over" feature guide data from parent injection
// or old data that wont be replaced in the future
// NOTE: Eventually the count will be used to know what features need to be tracked (before screenshots for instance)
for (let i = featureGuides.length - 1; i >= 0; i--) {
const { layerGuide, parent } = featureGuides[i];
if (deadLayers.includes(layerGuide.layerIndex) &&
parent !== undefined &&
// corner-case: empty data/missing tile -> flushes ALL layers,
// but that layer MAY BE inverted so we don't kill it.
!(('invert' in layerGuide && layerGuide.invert === true) ?? false))
featureGuides.splice(i, 1);
}
// remove dead layers from layersToBeLoaded
for (const deadLayer of deadLayers)
layersLoaded.add(deadLayer);
}
}
/** S2 Geometry Projection Tile */
export class S2Tile extends Tile {
type = 'S2';
corners;
/**
* @param context - the context to use (GPU or WebGL)
* @param tileInfo - Information about the tile
*/
constructor(context, tileInfo) {
const { id, face, zoom, x, y } = tileInfo;
super(context, id);
const { max, min, floor } = Math;
this.face = face;
this.i = x;
this.j = y;
const bbox = (this.bbox = bboxST(x, y, zoom));
this.faceST = [face, zoom, bbox[2] - bbox[0], bbox[0], bbox[3] - bbox[1], bbox[1]];
if (zoom >= 12)
this.#buildCorners();
// setup uniforms
this.uniforms = new Float32Array([
1, // isS2
face,
zoom,
bbox[0], // sLow
bbox[1], // tLow
bbox[2] - bbox[0], // deltaS
bbox[3] - bbox[1], // deltaT
]);
// build division
this.division = 16 / (1 << max(min(floor(zoom / 2), 4), 0));
// grab mask
this.mask = context.getMask(this.division, this);
}
/** Build the corners for the tile. Luckily only needs to be built once */
#buildCorners() {
const { face, bbox } = this;
this.corners = {
topLeft: pointMulScalar(pointNormalize(pointFromSTGL(face, bbox[0], bbox[3])), 6371008.8),
topRight: pointMulScalar(pointNormalize(pointFromSTGL(face, bbox[2], bbox[3])), 6371008.8),
bottomLeft: pointMulScalar(pointNormalize(pointFromSTGL(face, bbox[0], bbox[1])), 6371008.8),
bottomRight: pointMulScalar(pointNormalize(pointFromSTGL(face, bbox[2], bbox[1])), 6371008.8),
};
}
/**
* given a matrix, compute the corners screen positions
* @param projector - the camera's current view
*/
setScreenPositions(projector) {
if (this.corners !== undefined) {
const { eye } = projector;
const eyeKM = pointMulScalar(eye, 1000);
const matrix = projector.getMatrix('km');
// pull out the S2Points
const { bottomLeft, bottomRight, topLeft, topRight } = this.corners;
// project points and grab their x-y positions
const { x: blX, y: blY } = project(matrix, pointSub(bottomLeft, eyeKM));
const { x: brX, y: brY } = project(matrix, pointSub(bottomRight, eyeKM));
const { x: tlX, y: tlY } = project(matrix, pointSub(topLeft, eyeKM));
const { x: trX, y: trY } = project(matrix, pointSub(topRight, eyeKM));
// store for eventual uniform "upload"
this.bottomTop[0] = blX;
this.bottomTop[1] = blY;
this.bottomTop[2] = brX;
this.bottomTop[3] = brY;
this.bottomTop[4] = tlX;
this.bottomTop[5] = tlY;
this.bottomTop[6] = trX;
this.bottomTop[7] = trY;
// if WebGPU mask, we need to update the position buffer
super.setScreenPositions(projector);
}
}
}
/** Web Mercator Projection Tile */
export class WMTile extends Tile {
type = 'WM';
matrix = new Float32Array(16);
/**
* @param context - a GPU context or WebGL context
* @param tileInfo - Information about the tile
*/
constructor(context, tileInfo) {
const { id, x, y, zoom, wrappedID } = tileInfo;
super(context, id);
this.i = x;
this.j = y;
this.zoom = zoom;
this.wrappedID = wrappedID;
// TODO: bboxWM? And do I apply it to the uniforms?
// const bbox = this.bbox = bboxST(i, j, zoom)
this.bbox = bboxST(x, y, zoom);
// setup uniforms
this.uniforms = new Float32Array([
0, // isS2
0, // face
zoom, // zoom
// padding (unused by WM tiles)
0, // sLow
0, // tLow
1, // deltaS
1, // deltaT
]);
// grab mask
this.mask = context.getMask(1, this);
}
/**
* given a basic ortho matrix, adjust by the tile's offset and scale
* @param projector - the camera's current view
*/
setScreenPositions(projector) {
const { zoom, lon, lat } = projector;
const scale = Math.pow(2, zoom - this.zoom);
const offset = llToTilePx({ x: lon, y: lat }, [this.zoom, this.i, this.j], 1);
this.matrix = projector.getMatrix(scale, offset);
// build bottomTop
const { matrix } = this;
const bl = project(matrix, { x: 0, y: 0, z: 0 });
const br = project(matrix, { x: 1, y: 0, z: 0 });
const tl = project(matrix, { x: 0, y: 1, z: 0 });
const tr = project(matrix, { x: 1, y: 1, z: 0 });
// store for eventual uniform "upload"
this.bottomTop[0] = bl.x;
this.bottomTop[1] = bl.y;
this.bottomTop[2] = br.x;
this.bottomTop[3] = br.y;
this.bottomTop[4] = tl.x;
this.bottomTop[5] = tl.y;
this.bottomTop[6] = tr.x;
this.bottomTop[7] = tr.y;
super.setScreenPositions(projector);
}
}
/**
* Check if setA is a subset of set2
* @param setA - set to check
* @param set2 - set to check against
* @returns true if setA is a subset of set2
*/
function setBContainsA(setA, set2) {
// TODO: Remove this function to favor: setA.isSupersetOf(set2); (check it works live first)
for (const item of setA)
if (!set2.has(item))
return false;
return true;
}