UNPKG

s2maps-gpu

Version:

S2 Maps GPU - An open source, high-performance, and GPU-accelerated map engine for rendering large-scale, interactive maps.

627 lines (626 loc) 26.7 kB
import CollisionTester from './collisionTester.js'; import coalesceField from 'style/coalesceField.js'; import { featureSort } from '../util/featureSort.js'; import parseFeature from 's2/style/parseFeature.js'; import parseFilter from 'style/parseFilter.js'; import { DEFAULT_OPTIONS_WITHOUT_BIDI_SHAPING, shapeString } from 'unicode-shaper'; import { QUAD_SIZE_PATH, QUAD_SIZE_TEXT, buildGlyphPathQuads, buildGlyphPointQuads, } from './buildGlyphQuads.js'; import VectorWorker, { colorFunc, idToRGB } from '../vectorWorker.js'; import { getCenterPoints, getPointsAndPathsAlongLines, getPointsAndPathsAtCenterOfLines, getSpacedPoints, scaleShiftClipPoints, } from '../util/index.js'; /** Worker for processing glyph data */ export default class GlyphWorker extends VectorWorker { collisionTest = new CollisionTester(); imageStore; featureStore = new Map(); // tileID -> features sourceWorker; tileSize; /** * @param idGen - id generator to ensure features don't overlap * @param gpuType - the GPU context of the map renderer (WebGL(1|2) | WebGPU) * @param sourceWorker - the source worker to send requests to * @param imageStore - the image store to pull/request the needed glyphs/icons * @param tileSize - the tile size */ constructor(idGen, gpuType, sourceWorker, imageStore, tileSize) { super(idGen, gpuType); this.sourceWorker = sourceWorker; this.imageStore = imageStore; this.tileSize = tileSize; } /** * Setup a glyph layer for future processing of vector data * @param glyphLayer - the glyph layer * @returns the layer to process future glyph data */ setupLayer(glyphLayer) { const { name, layerIndex, source, layer, minzoom, maxzoom, filter, interactive, cursor, lch, overdraw, geoFilter, noShaping, // paint textSize, textFill, textStroke, textStrokeWidth, iconSize, // layout placement, spacing, textFamily, textField, textAnchor, textOffset, textPadding, textWordWrap, textAlign, textKerning, textLineHeight, iconFamily, iconField, iconAnchor, iconOffset, iconPadding, } = glyphLayer; // build featureCode designs const textDesign = [ [textSize], [textFill, colorFunc(lch)], [textStrokeWidth], [textStroke, colorFunc(lch)], ]; const iconDesign = [[iconSize]]; const glyphWorkerLayer = { type: 'glyph', name, layerIndex, source, layer, minzoom, maxzoom, filter: parseFilter(filter), textGetCode: this.buildCode(textDesign), iconGetCode: this.buildCode(iconDesign), // paint textSize: parseFeature(textSize), iconSize: parseFeature(iconSize), // layout placement: parseFeature(placement), spacing: parseFeature(spacing), textFamily: parseFeature(textFamily), textField: parseFeature(textField), textAnchor: parseFeature(textAnchor), textOffset: parseFeature(textOffset), textPadding: parseFeature(textPadding), textWordWrap: parseFeature(textWordWrap), textAlign: parseFeature(textAlign), textKerning: parseFeature(textKerning), textLineHeight: parseFeature(textLineHeight), iconFamily: parseFeature(iconFamily), iconField: parseFeature(iconField), iconAnchor: parseFeature(iconAnchor), iconOffset: parseFeature(iconOffset), iconPadding: parseFeature(iconPadding), // properties geoFilter, interactive, noShaping, cursor, overdraw, }; return glyphWorkerLayer; } /** * Build a Glyph Feature from input vector data * @param tile - the tile request * @param extent - the extent of the tile * @param feature - the input vector tile feature * @param glyphLayer - the glyph worker layer describing how to process the feature * @param mapID - the id of the map to ship the data back to * @param sourceName - the name of the source the data belongs to * @returns true if the feature was built */ async buildFeature(tile, extent, feature, glyphLayer, mapID, sourceName) { const { idGen, tileSize } = this; const { zoom } = tile; const { layerIndex, overdraw, interactive, geoFilter, noShaping } = glyphLayer; const { gpuType, imageStore, featureStore } = this; const { properties } = feature; let featureType = feature.geoType(); const pointType = featureType === 'Point' || featureType === 'MultiPoint'; const storeID = `${mapID}:${String(tile.id)}:${sourceName}`; if (!featureStore.has(storeID)) featureStore.set(storeID, []); // ensure that our imageStore is ready await imageStore.getReady(mapID); // filter as necessary if (geoFilter.includes('poly') && (featureType === 'Polygon' || featureType === 'MultiPolygon')) return false; if (geoFilter.includes('line') && (featureType === 'LineString' || featureType === 'MultiLineString')) return false; if (geoFilter.includes('point') && pointType) return false; // load geometry let geometry = feature.loadPoints(); if (geometry === undefined) return false; // get the placement, spacing, and orientation let placement = glyphLayer.placement([], properties, zoom); const spacing = (glyphLayer.spacing([], properties, zoom) / tileSize) * extent; // if we are placing along a line, but the geometry is a point, we skip if (pointType && placement !== 'point') placement = 'point'; // if geometry is a line or poly, we may need to flatten it depending upon the placement if (!pointType && placement !== 'line' && placement !== 'line-center-path') { if (placement === 'point') geometry = getSpacedPoints(geometry, featureType, spacing, extent); else if (placement === 'line-center-point') geometry = getCenterPoints(geometry, featureType); featureType = 'Point'; } // preprocess geometry const clip = scaleShiftClipPoints(geometry, extent, tile); if (clip.length === 0) return false; // build out all the individual s,t tile positions from the feature geometry const glyphs = []; for (const type of ['icon', 'text']) { // icon FIRST incase text draws over the icon // build all layout and paint parameters // per tile properties const deadCode = []; let field = coalesceField(glyphLayer[`${type}Field`](deadCode, properties, zoom), properties); // pre-process and shape the unicodes if (field.length === 0) continue; let fieldCodes = []; const color = []; const familyProcess = glyphLayer[`${type}Family`](deadCode, properties, zoom); const family = Array.isArray(familyProcess) ? familyProcess : [familyProcess]; // if icon, convert field to list of codes, otherwise create a unicode array let missing = false; if (type === 'text') { field = decodeHtmlEntities(field); try { field = shapeString(field, noShaping ? DEFAULT_OPTIONS_WITHOUT_BIDI_SHAPING : undefined); } catch (err) { console.error(field, field.split('').map((c) => c.charCodeAt(0)), err); } fieldCodes = field .split('') .map((char) => char.charCodeAt(0)) .map(String); imageStore.parseLigatures(mapID, family, fieldCodes); } else { fieldCodes = this.#mapIcon(mapID, family, field, color); } missing ||= imageStore.addMissingGlyph(mapID, tile.id, fieldCodes, family); // for rtree tests const size = glyphLayer[`${type}Size`](deadCode, properties, zoom); // grab codes const [gl1Code, gl2Code] = glyphLayer[`${type}GetCode`](zoom, properties); // prep glyph object const glyphBase = { // organization parameters id: 0, idRGB: [0, 0, 0, 0], type, overdraw, layerIndex, gl2Code, code: gpuType === 1 ? gl1Code : gl2Code, // layout family, field, fieldCodes, spacing, offset: glyphLayer[`${type}Offset`](deadCode, properties, zoom), padding: glyphLayer[`${type}Padding`](deadCode, properties, zoom), kerning: type === 'text' ? glyphLayer.textKerning(deadCode, properties, zoom) : 0, lineHeight: type === 'text' ? glyphLayer.textLineHeight(deadCode, properties, zoom) : 0, anchor: glyphLayer[`${type}Anchor`](deadCode, properties, zoom), wordWrap: type === 'text' ? glyphLayer.textWordWrap(deadCode, properties, zoom) : 0, align: type === 'text' ? glyphLayer.textAlign(deadCode, properties, zoom) : 'center', // paint size, // prep color, quads color, quads: [], // track if this feature is missing char or icon data missing, }; glyphs.push(glyphBase); } if (glyphs.length === 0) return false; // prep id tracker and store const ids = []; const store = featureStore.get(storeID); if (pointType) { for (const point of clip) { const id = idGen.getNum(); const idRGB = idToRGB(id); ids.push(id); for (const glyphBase of glyphs) { const glyph = { ...glyphBase, id, idRGB, glyphType: 'point', quads: [], // tile position s: point.x / extent, t: point.y / extent, filter: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // node proeprties minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity, }; store?.push(glyph); } } } else { const pathDataList = placement === 'line-center-path' ? getPointsAndPathsAtCenterOfLines(geometry, featureType, extent) : getPointsAndPathsAlongLines(geometry, featureType, spacing, extent); for (const pathData of pathDataList) { const id = idGen.getNum(); const idRGB = idToRGB(id); ids.push(id); for (const glyphBase of glyphs) { // path type const glyph = { ...glyphBase, id, idRGB, glyphType: 'path', quads: [], // store geometry data and type to properly build later extent, pathData, // ensure wordWrap is 0 wordWrap: 0, // setup filters filters: [], // node Properties nodes: [], }; store?.push(glyph); } } } // if interactive, store interactive properties if (interactive) { for (const id of ids) this._addInteractiveFeature(id, properties, glyphLayer); } return true; } /** * Flush a tile-request to the render thread * @param mapID - id of the map to ship the data back to * @param tile - tile request * @param sourceName - name of the source the data belongs to * @param wait - wait function. We need to wait for a response of missing glyph/icon data beore flushing */ async flush(mapID, tile, sourceName, wait) { const storeID = `${mapID}:${String(tile.id)}:${sourceName}`; const features = this.featureStore.get(storeID) ?? []; // check if we need to wait for a response of missing data const missing = features.some((f) => f.missing); if (missing) await wait; // if no missing data just flush now this.#flushReadyFeatures(mapID, tile, sourceName, features); // finish the flush await super.flush(mapID, tile, sourceName, wait); // cleanup this.featureStore.delete(storeID); } /** * We hit this function after flush's await is resolved. * actually flushing because the glyph response came back (if needed) * and all glyphs are ready to be processed * @param mapID - id of the map to ship the data back to * @param tile - tile request * @param sourceName - name of the source the data belongs to * @param features - glyph features to ship */ #flushReadyFeatures(mapID, tile, sourceName, features) { const { imageStore, collisionTest, tileSize } = this; const storeID = `${mapID}:${String(tile.id)}:${sourceName}`; // prepare collisionTest.clear(); const res = []; // remove empty features; sort the features before running the collisions features = features.filter((feature) => { if (feature.type === 'icon') return true; // corner case: sometimes the feature field could just be a group of empty codes for (const code of feature.fieldCodes) { const num = Number(code); if (!isNaN(num) && num >= 33) return true; } return false; }); features = features.sort(featureSort); for (const feature of features) { // Step 1: prebuild the glyph positions and bbox const glyphMap = imageStore.getGlyphSource(mapID); if (feature.glyphType === 'point') buildGlyphPointQuads(feature, glyphMap, tileSize); else buildGlyphPathQuads(feature, glyphMap, tileSize); // Step 2: check the collisionTest if we want to pre filter if (feature.quads.length !== 0 && (feature.overdraw || !collisionTest.collides(feature))) res.push(feature); } // replace the features with the filtered set this.featureStore.set(storeID, res); // Step 3: flush the features this.#flush(mapID, sourceName, tile.id); } /** * Build the glyph codes and colors for an icon * @param mapID - id of the map * @param family - icon family * @param field - icon field * @param outColor - output color * @returns glyph codes for the icon */ #mapIcon(mapID, family, field, outColor) { const fieldCodes = []; const fam = Array.isArray(family) ? family[0] : family; const { iconCache } = this.imageStore.getFamilyMap(mapID, fam); const icon = iconCache.get(field); if (icon !== undefined) { for (const { glyphID, color } of icon) { fieldCodes.push(glyphID); outColor.push(...color); } } return fieldCodes; } /** * Flush the features * @param mapID - id of the map * @param sourceName - name of the source * @param tileID - tile id */ #flush(mapID, sourceName, tileID) { const storeID = `${mapID}:${tileID}:${sourceName}`; const features = this.featureStore.get(storeID) ?? []; if (features.length === 0) return; if (this.gpuType === 3) { this.#flushPoints3(mapID, sourceName, tileID, features); } else this.#flushPoints2(mapID, sourceName, tileID, features); // cleanup this.featureStore.delete(storeID); } /** * WebGL 1 & 2 Flushing * @param mapID - id of the map to ship the data back to * @param sourceName - name of the source the data belongs to * @param tileID - tile id the data belongs to * @param features - features to ship */ #flushPoints2(mapID, sourceName, tileID, features) { // setup draw thread variables const glyphFilterData = []; const glyphFilterIDs = []; const glyphQuads = []; const glyphQuadIDs = []; const glyphColors = []; const featureGuide = []; // run through features and store let curlayerIndex = features[0].layerIndex; const curGlyphType = features[0].glyphType; let curType = features[0].type; let encoding = features[0].code; let codeStr = features[0].code.toString(); let filterOffset = 0; let quadOffset = 0; let filterCount = 0; let quadCount = 0; let indexPos = 0; // iterate features, store as we go for (const feature of features) { if (feature.glyphType !== 'point') continue; const { idRGB, type, layerIndex, code, color, quads, filter } = feature; // if there is a change in layer index or not the same feature set if ((quadCount > 0 || filterCount > 0) && (curlayerIndex !== layerIndex || codeStr !== code.toString() || curType !== type)) { // store featureGuide featureGuide.push(curlayerIndex, ~~(curGlyphType === 'path'), ~~(curType === 'icon'), filterOffset, filterCount, quadOffset, quadCount, encoding.length, ...encoding); // update to new codes curlayerIndex = layerIndex; codeStr = code.toString(); curType = type; encoding = code; // update offests filterOffset += filterCount; quadOffset += quadCount; // reset counts filterCount = 0; quadCount = 0; indexPos = 0; } // store the quads and colors glyphFilterData.push(...filter, indexPos++); glyphFilterIDs.push(...idRGB); filterCount++; glyphQuads.push(...quads); const quadSize = curGlyphType === 'point' ? QUAD_SIZE_TEXT : QUAD_SIZE_PATH; const qCount = quads.length / quadSize; quadCount += qCount; // add the feature's id for each quad for (let i = 0; i < qCount; i++) glyphQuadIDs.push(...idRGB); // add color data if (color.length > 0) glyphColors.push(...feature.color); else for (let i = 0; i < qCount; i++) glyphColors.push(255, 255, 255, 255); } // store last set if (quadCount > 0 || filterCount > 0) { featureGuide.push(curlayerIndex, ~~(curGlyphType === 'path'), ~~(curType === 'icon'), filterOffset, filterCount, quadOffset, quadCount, encoding.length, ...encoding); } // filter data const glyphFilterBuffer = new Float32Array(glyphFilterData).buffer; const glyphFilterIDBuffer = new Uint8ClampedArray(glyphFilterIDs).buffer; // quad draw data const glyphQuadBuffer = new Float32Array(glyphQuads).buffer; const glyphQuadIDBuffer = new Uint8ClampedArray(glyphQuadIDs).buffer; const glyphColorBuffer = new Uint8ClampedArray(glyphColors).buffer; const featureGuideBuffer = new Float32Array(featureGuide).buffer; const message = { mapID, type: 'glyph', sourceName, tileID, glyphFilterBuffer, glyphFilterIDBuffer, glyphQuadBuffer, glyphQuadIDBuffer, glyphColorBuffer, featureGuideBuffer, }; // ship the data postMessage(message, [ glyphFilterBuffer, glyphFilterIDBuffer, glyphQuadBuffer, glyphQuadIDBuffer, glyphColorBuffer, featureGuideBuffer, ]); } /** * WebGPU glyph data to be flushed / sent to the front-end * @param mapID - map id to ship the data back to * @param sourceName - name of the source the data belongs to * @param tileID - tile id the data belongs to * @param features - collection of glyph features to flush into the renderer */ #flushPoints3(mapID, sourceName, tileID, features) { // ID => { index: resultIndex, count: how many share the same resultIndex } let currIndex = 0; const resultIndexMap = new Map(); for (const { id } of features) { if (!resultIndexMap.has(id)) resultIndexMap.set(id, currIndex++); } // setup draw thread variables const glyphFilterData = []; const glyphQuads = []; const glyphQuadIDs = []; const glyphColors = []; const featureGuide = []; // run through features and store let curlayerIndex = features[0].layerIndex; let curType = features[0].type; let curGlyphType = features[0].glyphType; let encoding = features[0].code; let codeStr = features[0].code.toString(); let filterOffset = 0; let quadOffset = 0; let filterCount = 0; let quadCount = 0; // iterate features, store as we go for (const feature of features) { const { id, glyphType, type, layerIndex, code, color, quads } = feature; // if there is a change in layer index or if ((quadCount > 0 || filterCount > 0) && (curlayerIndex !== layerIndex || codeStr !== code.toString() || curGlyphType !== glyphType || curType !== type)) { // store featureGuide featureGuide.push(curlayerIndex, ~~(curGlyphType === 'path'), ~~(curType === 'icon'), filterOffset, filterCount, quadOffset, quadCount, encoding.length, ...encoding); // update to new codes curlayerIndex = layerIndex; codeStr = code.toString(); curGlyphType = glyphType; curType = type; encoding = code; // update offests filterOffset += filterCount; quadOffset += quadCount; // reset counts filterCount = 0; quadCount = 0; } // update filters index, store it, and store the ID, hiding the count inside the id const resultMap = resultIndexMap.get(id) ?? 0; if (glyphType === 'point') { glyphFilterData.push(...feature.filter, storeAsFloat32(resultMap), storeAsFloat32(id), -1, -1, -1, -1, -1, -1); filterCount++; } else { for (const filter of feature.filters) { glyphFilterData.push(...filter, storeAsFloat32(resultMap), storeAsFloat32(id), -1); filterCount++; } } glyphQuads.push(...quads); const quadSize = curGlyphType === 'point' ? QUAD_SIZE_TEXT : QUAD_SIZE_PATH; const qCount = quads.length / quadSize; quadCount += qCount; // add the feature's index for each quad for (let i = 0; i < qCount; i++) glyphQuadIDs.push(resultMap); // add color data if (color.length > 0) glyphColors.push(...color.map((c) => c / 255)); else for (let i = 0; i < qCount; i++) glyphColors.push(1, 1, 1, 1); } // store last set if (quadCount > 0 || filterCount > 0) { featureGuide.push(curlayerIndex, ~~(curGlyphType === 'path'), ~~(curType === 'icon'), filterOffset, filterCount, quadOffset, quadCount, encoding.length, ...encoding); } // filter data const glyphFilterBuffer = new Float32Array(glyphFilterData).buffer; // unused by WebGPU const glyphFilterIDBuffer = new Uint8ClampedArray([0]).buffer; // quad draw data const glyphQuadBuffer = new Float32Array(glyphQuads).buffer; // actually an index buffer not ID buffer const glyphQuadIDBuffer = new Uint32Array(glyphQuadIDs).buffer; const glyphColorBuffer = new Float32Array(glyphColors).buffer; const featureGuideBuffer = new Float32Array(featureGuide).buffer; const message = { mapID, type: 'glyph', sourceName, tileID, glyphFilterBuffer, glyphFilterIDBuffer, glyphQuadBuffer, glyphQuadIDBuffer, glyphColorBuffer, featureGuideBuffer, }; // ship the data postMessage(message, [ glyphFilterBuffer, glyphFilterIDBuffer, glyphQuadBuffer, glyphQuadIDBuffer, glyphColorBuffer, featureGuideBuffer, ]); } } /** * Store a value as a 32 bit float * @param u32value - a 32 bit unsigned integer * @returns a 32 bit float */ function storeAsFloat32(u32value) { const buffer = new ArrayBuffer(4); const u32View = new Uint32Array(buffer); const f32View = new Float32Array(buffer); u32View[0] = u32value; return f32View[0]; } /** * Decode HTML entities * @param input - the string to decode * @returns the decoded string */ function decodeHtmlEntities(input) { return input.replace(/&#x([0-9A-Fa-f]+);/g, function (_, hex) { return String.fromCharCode(parseInt(hex, 16)); }); }