import {LineLayoutArray, LineExtLayoutArray} from '../array_types.g'; import {members as layoutAttributes} from './line_attributes'; import {members as layoutAttributesExt} from './line_attributes_ext'; import {SegmentVector} from '../segment'; import {ProgramConfigurationSet} from '../program_configuration'; import {TriangleIndexArray} from '../index_array_type'; import {EXTENT} from '../extent'; import mvt from '@mapbox/vector-tile'; const vectorTileFeatureTypes = mvt.VectorTileFeature.types; import {register} from '../../util/web_worker_transfer'; import {hasPattern, addPatternDependencies} from './pattern_bucket_features'; import {loadGeometry} from '../load_geometry'; import {toEvaluationFeature} from '../evaluation_feature'; import {EvaluationParameters} from '../../style/evaluation_parameters'; import type {CanonicalTileID} from '../../source/tile_id'; import type { Bucket, BucketParameters, BucketFeature, IndexedFeature, PopulateParameters } from '../bucket'; import type {LineStyleLayer} from '../../style/style_layer/line_style_layer'; import type Point from '@mapbox/point-geometry'; import type {Segment} from '../segment'; import {RGBAImage} from '../../util/image'; import type {Context} from '../../gl/context'; import type {Texture} from '../../render/texture'; import type {IndexBuffer} from '../../gl/index_buffer'; import type {VertexBuffer} from '../../gl/vertex_buffer'; import type {FeatureStates} from '../../source/source_state'; import type {ImagePosition} from '../../render/image_atlas'; import type {VectorTileLayer} from '@mapbox/vector-tile'; // NOTE ON EXTRUDE SCALE: // scale the extrusion vector so that the normal length is this value. // contains the "texture" normals (-1..1). this is distinct from the extrude // normals for line joins, because the x-value remains 0 for the texture // normal array, while the extrude normal actually moves the vertex to create // the acute/bevelled line join. const EXTRUDE_SCALE = 63; /* * Sharp corners cause dashed lines to tilt because the distance along the line * is the same at both the inner and outer corners. To improve the appearance of * dashed lines we add extra points near sharp corners so that a smaller part * of the line is tilted. * * COS_HALF_SHARP_CORNER controls how sharp a corner has to be for us to add an * extra vertex. The default is 75 degrees. * * The newly created vertices are placed SHARP_CORNER_OFFSET pixels from the corner. */ const COS_HALF_SHARP_CORNER = Math.cos(75 / 2 * (Math.PI / 180)); const SHARP_CORNER_OFFSET = 15; // Angle per triangle for approximating round line joins. const DEG_PER_TRIANGLE = 20; // The number of bits that is used to store the line distance in the buffer. const LINE_DISTANCE_BUFFER_BITS = 15; // We don't have enough bits for the line distance as we'd like to have, so // use this value to scale the line distance (in tile units) down to a smaller // value. This lets us store longer distances while sacrificing precision. const LINE_DISTANCE_SCALE = 1 / 2; // The maximum line distance, in tile units, that fits in the buffer. const MAX_LINE_DISTANCE = Math.pow(2, LINE_DISTANCE_BUFFER_BITS - 1) / LINE_DISTANCE_SCALE; type LineClips = { start: number; end: number; }; type GradientTexture = { texture?: Texture; gradient?: RGBAImage; version?: number; }; /** * @internal * Line bucket class */ export class LineBucket implements Bucket { distance: number; totalDistance: number; maxLineLength: number; scaledDistance: number; lineClips?: LineClips; e1: number; e2: number; index: number; zoom: number; overscaling: number; layers: Array; layerIds: Array; gradients: {[x: string]: GradientTexture}; stateDependentLayers: Array; stateDependentLayerIds: Array; patternFeatures: Array; lineClipsArray: Array; layoutVertexArray: LineLayoutArray; layoutVertexBuffer: VertexBuffer; layoutVertexArray2: LineExtLayoutArray; layoutVertexBuffer2: VertexBuffer; indexArray: TriangleIndexArray; indexBuffer: IndexBuffer; hasPattern: boolean; programConfigurations: ProgramConfigurationSet; segments: SegmentVector; uploaded: boolean; constructor(options: BucketParameters) { this.zoom = options.zoom; this.overscaling = options.overscaling; this.layers = options.layers; this.layerIds = this.layers.map(layer => layer.id); this.index = options.index; this.hasPattern = false; this.patternFeatures = []; this.lineClipsArray = []; this.gradients = {}; this.layers.forEach(layer => { this.gradients[layer.id] = {}; }); this.layoutVertexArray = new LineLayoutArray(); this.layoutVertexArray2 = new LineExtLayoutArray(); this.indexArray = new TriangleIndexArray(); this.programConfigurations = new ProgramConfigurationSet(options.layers, options.zoom); this.segments = new SegmentVector(); this.maxLineLength = 0; this.stateDependentLayerIds = this.layers.filter((l) => l.isStateDependent()).map((l) => l.id); } populate(features: Array, options: PopulateParameters, canonical: CanonicalTileID) { this.hasPattern = hasPattern('line', this.layers, options); const lineSortKey = this.layers[0].layout.get('line-sort-key'); const sortFeaturesByKey = !lineSortKey.isConstant(); const bucketFeatures: BucketFeature[] = []; for (const {feature, id, index, sourceLayerIndex} of features) { const needGeometry = this.layers[0]._featureFilter.needGeometry; const evaluationFeature = toEvaluationFeature(feature, needGeometry); if (!this.layers[0]._featureFilter.filter(new EvaluationParameters(this.zoom), evaluationFeature, canonical)) continue; const sortKey = sortFeaturesByKey ? lineSortKey.evaluate(evaluationFeature, {}, canonical) : undefined; const bucketFeature: BucketFeature = { id, properties: feature.properties, type: feature.type, sourceLayerIndex, index, geometry: needGeometry ? evaluationFeature.geometry : loadGeometry(feature), patterns: {}, sortKey }; bucketFeatures.push(bucketFeature); } if (sortFeaturesByKey) { bucketFeatures.sort((a, b) => { return (a.sortKey) - (b.sortKey); }); } for (const bucketFeature of bucketFeatures) { const {geometry, index, sourceLayerIndex} = bucketFeature; if (this.hasPattern) { const patternBucketFeature = addPatternDependencies('line', this.layers, bucketFeature, this.zoom, options); // pattern features are added only once the pattern is loaded into the image atlas // so are stored during populate until later updated with positions by tile worker in addFeatures this.patternFeatures.push(patternBucketFeature); } else { this.addFeature(bucketFeature, geometry, index, canonical, {}); } const feature = features[index].feature; options.featureIndex.insert(feature, geometry, index, sourceLayerIndex, this.index); } } update(states: FeatureStates, vtLayer: VectorTileLayer, imagePositions: {[_: string]: ImagePosition}) { if (!this.stateDependentLayers.length) return; this.programConfigurations.updatePaintArrays(states, vtLayer, this.stateDependentLayers, imagePositions); } addFeatures(options: PopulateParameters, canonical: CanonicalTileID, imagePositions: {[_: string]: ImagePosition}) { for (const feature of this.patternFeatures) { this.addFeature(feature, feature.geometry, feature.index, canonical, imagePositions); } } isEmpty() { return this.layoutVertexArray.length === 0; } uploadPending() { return !this.uploaded || this.programConfigurations.needsUpload; } upload(context: Context) { if (!this.uploaded) { if (this.layoutVertexArray2.length !== 0) { this.layoutVertexBuffer2 = context.createVertexBuffer(this.layoutVertexArray2, layoutAttributesExt); } this.layoutVertexBuffer = context.createVertexBuffer(this.layoutVertexArray, layoutAttributes); this.indexBuffer = context.createIndexBuffer(this.indexArray); } this.programConfigurations.upload(context); this.uploaded = true; } destroy() { if (!this.layoutVertexBuffer) return; this.layoutVertexBuffer.destroy(); this.indexBuffer.destroy(); this.programConfigurations.destroy(); this.segments.destroy(); } lineFeatureClips(feature: BucketFeature): LineClips | undefined { if (!!feature.properties && Object.prototype.hasOwnProperty.call(feature.properties, 'mapbox_clip_start') && Object.prototype.hasOwnProperty.call(feature.properties, 'mapbox_clip_end')) { const start = +feature.properties['mapbox_clip_start']; const end = +feature.properties['mapbox_clip_end']; return {start, end}; } } addFeature(feature: BucketFeature, geometry: Array>, index: number, canonical: CanonicalTileID, imagePositions: {[_: string]: ImagePosition}) { const layout = this.layers[0].layout; const join = layout.get('line-join').evaluate(feature, {}); const cap = layout.get('line-cap'); const miterLimit = layout.get('line-miter-limit'); const roundLimit = layout.get('line-round-limit'); this.lineClips = this.lineFeatureClips(feature); for (const line of geometry) { this.addLine(line, feature, join, cap, miterLimit, roundLimit); } this.programConfigurations.populatePaintArrays(this.layoutVertexArray.length, feature, index, imagePositions, canonical); } addLine(vertices: Array, feature: BucketFeature, join: string, cap: string, miterLimit: number, roundLimit: number) { this.distance = 0; this.scaledDistance = 0; this.totalDistance = 0; if (this.lineClips) { this.lineClipsArray.push(this.lineClips); // Calculate the total distance, in tile units, of this tiled line feature for (let i = 0; i < vertices.length - 1; i++) { this.totalDistance += vertices[i].dist(vertices[i + 1]); } this.updateScaledDistance(); this.maxLineLength = Math.max(this.maxLineLength, this.totalDistance); } const isPolygon = vectorTileFeatureTypes[feature.type] === 'Polygon'; // If the line has duplicate vertices at the ends, adjust start/length to remove them. let len = vertices.length; while (len >= 2 && vertices[len - 1].equals(vertices[len - 2])) { len--; } let first = 0; while (first < len - 1 && vertices[first].equals(vertices[first + 1])) { first++; } // Ignore invalid geometry. if (len < (isPolygon ? 3 : 2)) return; if (join === 'bevel') miterLimit = 1.05; const sharpCornerOffset = this.overscaling <= 16 ? SHARP_CORNER_OFFSET * EXTENT / (512 * this.overscaling) : 0; // we could be more precise, but it would only save a negligible amount of space const segment = this.segments.prepareSegment(len * 10, this.layoutVertexArray, this.indexArray); let currentVertex: Point; let prevVertex: Point; let nextVertex: Point; let prevNormal: Point; let nextNormal: Point; // the last two vertices added this.e1 = this.e2 = -1; if (isPolygon) { currentVertex = vertices[len - 2]; nextNormal = vertices[first].sub(currentVertex)._unit()._perp(); } for (let i = first; i < len; i++) { nextVertex = i === len - 1 ? (isPolygon ? vertices[first + 1] : undefined) : // if it's a polygon, treat the last vertex like the first vertices[i + 1]; // just the next vertex // if two consecutive vertices exist, skip the current one if (nextVertex && vertices[i].equals(nextVertex)) continue; if (nextNormal) prevNormal = nextNormal; if (currentVertex) prevVertex = currentVertex; currentVertex = vertices[i]; // Calculate the normal towards the next vertex in this line. In case // there is no next vertex, pretend that the line is continuing straight, // meaning that we are just using the previous normal. nextNormal = nextVertex ? nextVertex.sub(currentVertex)._unit()._perp() : prevNormal; // If we still don't have a previous normal, this is the beginning of a // non-closed line, so we're doing a straight "join". prevNormal = prevNormal || nextNormal; // Determine the normal of the join extrusion. It is the angle bisector // of the segments between the previous line and the next line. // In the case of 180° angles, the prev and next normals cancel each other out: // prevNormal + nextNormal = (0, 0), its magnitude is 0, so the unit vector would be // undefined. In that case, we're keeping the joinNormal at (0, 0), so that the cosHalfAngle // below will also become 0 and miterLength will become Infinity. let joinNormal = prevNormal.add(nextNormal); if (joinNormal.x !== 0 || joinNormal.y !== 0) { joinNormal._unit(); } /* joinNormal prevNormal * ↖ ↑ * .________. prevVertex * | * nextNormal ← | currentVertex * | * nextVertex ! * */ // calculate cosines of the angle (and its half) using dot product const cosAngle = prevNormal.x * nextNormal.x + prevNormal.y * nextNormal.y; const cosHalfAngle = joinNormal.x * nextNormal.x + joinNormal.y * nextNormal.y; // Calculate the length of the miter (the ratio of the miter to the width) // as the inverse of cosine of the angle between next and join normals const miterLength = cosHalfAngle !== 0 ? 1 / cosHalfAngle : Infinity; // approximate angle from cosine const approxAngle = 2 * Math.sqrt(2 - 2 * cosHalfAngle); const isSharpCorner = cosHalfAngle < COS_HALF_SHARP_CORNER && prevVertex && nextVertex; const lineTurnsLeft = prevNormal.x * nextNormal.y - prevNormal.y * nextNormal.x > 0; if (isSharpCorner && i > first) { const prevSegmentLength = currentVertex.dist(prevVertex); if (prevSegmentLength > 2 * sharpCornerOffset) { const newPrevVertex = currentVertex.sub(currentVertex.sub(prevVertex)._mult(sharpCornerOffset / prevSegmentLength)._round()); this.updateDistance(prevVertex, newPrevVertex); this.addCurrentVertex(newPrevVertex, prevNormal, 0, 0, segment); prevVertex = newPrevVertex; } } // The join if a middle vertex, otherwise the cap. const middleVertex = prevVertex && nextVertex; let currentJoin = middleVertex ? join : isPolygon ? 'butt' : cap; if (middleVertex && currentJoin === 'round') { if (miterLength < roundLimit) { currentJoin = 'miter'; } else if (miterLength <= 2) { currentJoin = 'fakeround'; } } if (currentJoin === 'miter' && miterLength > miterLimit) { currentJoin = 'bevel'; } if (currentJoin === 'bevel') { // The maximum extrude length is 128 / 63 = 2 times the width of the line // so if miterLength >= 2 we need to draw a different type of bevel here. if (miterLength > 2) currentJoin = 'flipbevel'; // If the miterLength is really small and the line bevel wouldn't be visible, // just draw a miter join to save a triangle. if (miterLength < miterLimit) currentJoin = 'miter'; } // Calculate how far along the line the currentVertex is if (prevVertex) this.updateDistance(prevVertex, currentVertex); if (currentJoin === 'miter') { joinNormal._mult(miterLength); this.addCurrentVertex(currentVertex, joinNormal, 0, 0, segment); } else if (currentJoin === 'flipbevel') { // miter is too big, flip the direction to make a beveled join if (miterLength > 100) { // Almost parallel lines joinNormal = nextNormal.mult(-1); } else { const bevelLength = miterLength * prevNormal.add(nextNormal).mag() / prevNormal.sub(nextNormal).mag(); joinNormal._perp()._mult(bevelLength * (lineTurnsLeft ? -1 : 1)); } this.addCurrentVertex(currentVertex, joinNormal, 0, 0, segment); this.addCurrentVertex(currentVertex, joinNormal.mult(-1), 0, 0, segment); } else if (currentJoin === 'bevel' || currentJoin === 'fakeround') { const offset = -Math.sqrt(miterLength * miterLength - 1); const offsetA = lineTurnsLeft ? offset : 0; const offsetB = lineTurnsLeft ? 0 : offset; // Close previous segment with a bevel if (prevVertex) { this.addCurrentVertex(currentVertex, prevNormal, offsetA, offsetB, segment); } if (currentJoin === 'fakeround') { // The join angle is sharp enough that a round join would be visible. // Bevel joins fill the gap between segments with a single pie slice triangle. // Create a round join by adding multiple pie slices. The join isn't actually round, but // it looks like it is at the sizes we render lines at. // pick the number of triangles for approximating round join by based on the angle between normals const n = Math.round((approxAngle * 180 / Math.PI) / DEG_PER_TRIANGLE); for (let m = 1; m < n; m++) { let t = m / n; if (t !== 0.5) { // approximate spherical interpolation https://observablehq.com/@mourner/approximating-geometric-slerp const t2 = t - 0.5; const A = 1.0904 + cosAngle * (-3.2452 + cosAngle * (3.55645 - cosAngle * 1.43519)); const B = 0.848013 + cosAngle * (-1.06021 + cosAngle * 0.215638); t = t + t * t2 * (t - 1) * (A * t2 * t2 + B); } const extrude = nextNormal.sub(prevNormal)._mult(t)._add(prevNormal)._unit()._mult(lineTurnsLeft ? -1 : 1); this.addHalfVertex(currentVertex, extrude.x, extrude.y, false, lineTurnsLeft, 0, segment); } } if (nextVertex) { // Start next segment this.addCurrentVertex(currentVertex, nextNormal, -offsetA, -offsetB, segment); } } else if (currentJoin === 'butt') { this.addCurrentVertex(currentVertex, joinNormal, 0, 0, segment); // butt cap } else if (currentJoin === 'square') { const offset = prevVertex ? 1 : -1; // closing or starting square cap this.addCurrentVertex(currentVertex, joinNormal, offset, offset, segment); } else if (currentJoin === 'round') { if (prevVertex) { // Close previous segment with butt this.addCurrentVertex(currentVertex, prevNormal, 0, 0, segment); // Add round cap or linejoin at end of segment this.addCurrentVertex(currentVertex, prevNormal, 1, 1, segment, true); } if (nextVertex) { // Add round cap before first segment this.addCurrentVertex(currentVertex, nextNormal, -1, -1, segment, true); // Start next segment with a butt this.addCurrentVertex(currentVertex, nextNormal, 0, 0, segment); } } if (isSharpCorner && i < len - 1) { const nextSegmentLength = currentVertex.dist(nextVertex); if (nextSegmentLength > 2 * sharpCornerOffset) { const newCurrentVertex = currentVertex.add(nextVertex.sub(currentVertex)._mult(sharpCornerOffset / nextSegmentLength)._round()); this.updateDistance(currentVertex, newCurrentVertex); this.addCurrentVertex(newCurrentVertex, nextNormal, 0, 0, segment); currentVertex = newCurrentVertex; } } } } /** * Add two vertices to the buffers. * * @param p - the line vertex to add buffer vertices for * @param normal - vertex normal * @param endLeft - extrude to shift the left vertex along the line * @param endRight - extrude to shift the left vertex along the line * @param segment - the segment object to add the vertex to * @param round - whether this is a round cap */ addCurrentVertex(p: Point, normal: Point, endLeft: number, endRight: number, segment: Segment, round: boolean = false) { // left and right extrude vectors, perpendicularly shifted by endLeft/endRight const leftX = normal.x + normal.y * endLeft; const leftY = normal.y - normal.x * endLeft; const rightX = -normal.x + normal.y * endRight; const rightY = -normal.y - normal.x * endRight; this.addHalfVertex(p, leftX, leftY, round, false, endLeft, segment); this.addHalfVertex(p, rightX, rightY, round, true, -endRight, segment); // There is a maximum "distance along the line" that we can store in the buffers. // When we get close to the distance, reset it to zero and add the vertex again with // a distance of zero. The max distance is determined by the number of bits we allocate // to `linesofar`. if (this.distance > MAX_LINE_DISTANCE / 2 && this.totalDistance === 0) { this.distance = 0; this.updateScaledDistance(); this.addCurrentVertex(p, normal, endLeft, endRight, segment, round); } } addHalfVertex({x, y}: Point, extrudeX: number, extrudeY: number, round: boolean, up: boolean, dir: number, segment: Segment) { const totalDistance = this.lineClips ? this.scaledDistance * (MAX_LINE_DISTANCE - 1) : this.scaledDistance; // scale down so that we can store longer distances while sacrificing precision. const linesofarScaled = totalDistance * LINE_DISTANCE_SCALE; this.layoutVertexArray.emplaceBack( // a_pos_normal // Encode round/up the least significant bits (x << 1) + (round ? 1 : 0), (y << 1) + (up ? 1 : 0), // a_data // add 128 to store a byte in an unsigned byte Math.round(EXTRUDE_SCALE * extrudeX) + 128, Math.round(EXTRUDE_SCALE * extrudeY) + 128, // Encode the -1/0/1 direction value into the first two bits of .z of a_data. // Combine it with the lower 6 bits of `linesofarScaled` (shifted by 2 bits to make // room for the direction value). The upper 8 bits of `linesofarScaled` are placed in // the `w` component. ((dir === 0 ? 0 : (dir < 0 ? -1 : 1)) + 1) | ((linesofarScaled & 0x3F) << 2), linesofarScaled >> 6); // Constructs a second vertex buffer with higher precision line progress if (this.lineClips) { const progressRealigned = this.scaledDistance - this.lineClips.start; const endClipRealigned = this.lineClips.end - this.lineClips.start; const uvX = progressRealigned / endClipRealigned; this.layoutVertexArray2.emplaceBack(uvX, this.lineClipsArray.length); } const e = segment.vertexLength++; if (this.e1 >= 0 && this.e2 >= 0) { this.indexArray.emplaceBack(this.e1, this.e2, e); segment.primitiveLength++; } if (up) { this.e2 = e; } else { this.e1 = e; } } updateScaledDistance() { // Knowing the ratio of the full linestring covered by this tiled feature, as well // as the total distance (in tile units) of this tiled feature, and the distance // (in tile units) of the current vertex, we can determine the relative distance // of this vertex along the full linestring feature and scale it to [0, 2^15) this.scaledDistance = this.lineClips ? this.lineClips.start + (this.lineClips.end - this.lineClips.start) * this.distance / this.totalDistance : this.distance; } updateDistance(prev: Point, next: Point) { this.distance += prev.dist(next); this.updateScaledDistance(); } } register('LineBucket', LineBucket, {omit: ['layers', 'patternFeatures']});