diff --git a/debug/canvas.html b/debug/canvas.html new file mode 100644 index 00000000000..3b2a0087df6 --- /dev/null +++ b/debug/canvas.html @@ -0,0 +1,80 @@ + + + + Mapbox GL JS debug page + + + + + + + + +Canvas not supported + +
+ + + + + + + diff --git a/src/data/bucket/symbol_bucket.js b/src/data/bucket/symbol_bucket.js index f0f561da8c0..9a15cba5f73 100644 --- a/src/data/bucket/symbol_bucket.js +++ b/src/data/bucket/symbol_bucket.js @@ -32,6 +32,7 @@ const elementArrayType = createElementArrayType(); const layoutAttributes = [ {name: 'a_pos_offset', components: 4, type: 'Int16'}, + {name: 'a_label_pos', components: 2, type: 'Int16'}, {name: 'a_data', components: 4, type: 'Uint16'} ]; @@ -60,15 +61,16 @@ const symbolInterfaces = { }, collisionBox: { // used to render collision boxes for debugging purposes layoutAttributes: [ - {name: 'a_pos', components: 2, type: 'Int16'}, - {name: 'a_extrude', components: 2, type: 'Int16'}, - {name: 'a_data', components: 2, type: 'Uint8'} + {name: 'a_pos', components: 2, type: 'Int16'}, + {name: 'a_anchor_pos', components: 2, type: 'Int16'}, + {name: 'a_extrude', components: 2, type: 'Int16'}, + {name: 'a_data', components: 2, type: 'Uint8'} ], elementArrayType: createElementArrayType(2) } }; -function addVertex(array, x, y, ox, oy, tx, ty, sizeVertex, minzoom, maxzoom, labelminzoom, labelangle) { +function addVertex(array, x, y, ox, oy, labelX, labelY, tx, ty, sizeVertex, minzoom, maxzoom, labelminzoom, labelangle) { array.emplaceBack( // a_pos_offset x, @@ -76,6 +78,10 @@ function addVertex(array, x, y, ox, oy, tx, ty, sizeVertex, minzoom, maxzoom, la Math.round(ox * 64), Math.round(oy * 64), + // a_label_pos + labelX, + labelY, + // a_data tx, // x coordinate of symbol on glyph atlas texture ty, // y coordinate of symbol on glyph atlas texture @@ -95,11 +101,14 @@ function addVertex(array, x, y, ox, oy, tx, ty, sizeVertex, minzoom, maxzoom, la ); } -function addCollisionBoxVertex(layoutVertexArray, point, extrude, maxZoom, placementZoom) { +function addCollisionBoxVertex(layoutVertexArray, point, anchor, extrude, maxZoom, placementZoom) { return layoutVertexArray.emplaceBack( // pos point.x, point.y, + // a_anchor_pos + anchor.x, + anchor.y, // extrude Math.round(extrude.x), Math.round(extrude.y), @@ -523,6 +532,10 @@ class SymbolBucket { const layer = this.layers[0]; const layout = layer.layout; + // Symbols that don't show until greater than the CollisionTile's maxScale won't even be added + // to the buffers. Even though pan operations on a tilted map might cause the symbol to be + // displayable, we have to stay conservative here because the CollisionTile didn't consider + // this scale range. const maxScale = collisionTile.maxScale; const textAlongLine = layout['text-rotation-alignment'] === 'map' && layout['symbol-placement'] === 'line'; @@ -608,7 +621,8 @@ class SymbolBucket { textAlongLine, collisionTile.angle, symbolInstance.featureProperties, - symbolInstance.writingModes); + symbolInstance.writingModes, + symbolInstance.anchor); } } @@ -629,7 +643,9 @@ class SymbolBucket { layout['icon-keep-upright'], iconAlongLine, collisionTile.angle, - symbolInstance.featureProperties + symbolInstance.featureProperties, + null, + symbolInstance.anchor ); } } @@ -639,7 +655,7 @@ class SymbolBucket { if (showCollisionBoxes) this.addToDebugBuffers(collisionTile); } - addSymbols(arrays, quads, scale, sizeVertex, keepUpright, alongLine, placementAngle, featureProperties, writingModes) { + addSymbols(arrays, quads, scale, sizeVertex, keepUpright, alongLine, placementAngle, featureProperties, writingModes, labelAnchor) { const elementArray = arrays.elementArray; const layoutVertexArray = arrays.layoutVertexArray; @@ -676,10 +692,10 @@ class SymbolBucket { const segment = arrays.prepareSegment(4); const index = segment.vertexLength; - addVertex(layoutVertexArray, anchorPoint.x, anchorPoint.y, tl.x, tl.y, tex.x, tex.y, sizeVertex, minZoom, maxZoom, placementZoom, glyphAngle); - addVertex(layoutVertexArray, anchorPoint.x, anchorPoint.y, tr.x, tr.y, tex.x + tex.w, tex.y, sizeVertex, minZoom, maxZoom, placementZoom, glyphAngle); - addVertex(layoutVertexArray, anchorPoint.x, anchorPoint.y, bl.x, bl.y, tex.x, tex.y + tex.h, sizeVertex, minZoom, maxZoom, placementZoom, glyphAngle); - addVertex(layoutVertexArray, anchorPoint.x, anchorPoint.y, br.x, br.y, tex.x + tex.w, tex.y + tex.h, sizeVertex, minZoom, maxZoom, placementZoom, glyphAngle); + addVertex(layoutVertexArray, anchorPoint.x, anchorPoint.y, tl.x, tl.y, labelAnchor.x, labelAnchor.y, tex.x, tex.y, sizeVertex, minZoom, maxZoom, placementZoom, glyphAngle); + addVertex(layoutVertexArray, anchorPoint.x, anchorPoint.y, tr.x, tr.y, labelAnchor.x, labelAnchor.y, tex.x + tex.w, tex.y, sizeVertex, minZoom, maxZoom, placementZoom, glyphAngle); + addVertex(layoutVertexArray, anchorPoint.x, anchorPoint.y, bl.x, bl.y, labelAnchor.x, labelAnchor.y, tex.x, tex.y + tex.h, sizeVertex, minZoom, maxZoom, placementZoom, glyphAngle); + addVertex(layoutVertexArray, anchorPoint.x, anchorPoint.y, br.x, br.y, labelAnchor.x, labelAnchor.y, tex.x + tex.w, tex.y + tex.h, sizeVertex, minZoom, maxZoom, placementZoom, glyphAngle); elementArray.emplaceBack(index, index + 1, index + 2); elementArray.emplaceBack(index + 1, index + 2, index + 3); @@ -709,7 +725,12 @@ class SymbolBucket { for (let b = feature.boxStartIndex; b < feature.boxEndIndex; b++) { const box = this.collisionBoxArray.get(b); - const anchorPoint = box.anchorPoint; + if (collisionTile.perspectiveRatio === 1 && box.maxScale < 1) { + // These boxes aren't used on unpitched maps + // See CollisionTile#insertCollisionFeature + continue; + } + const boxAnchorPoint = box.anchorPoint; const tl = new Point(box.x1, box.y1 * yStretch)._rotate(angle); const tr = new Point(box.x2, box.y1 * yStretch)._rotate(angle); @@ -722,10 +743,10 @@ class SymbolBucket { const segment = arrays.prepareSegment(4); const index = segment.vertexLength; - addCollisionBoxVertex(layoutVertexArray, anchorPoint, tl, maxZoom, placementZoom); - addCollisionBoxVertex(layoutVertexArray, anchorPoint, tr, maxZoom, placementZoom); - addCollisionBoxVertex(layoutVertexArray, anchorPoint, br, maxZoom, placementZoom); - addCollisionBoxVertex(layoutVertexArray, anchorPoint, bl, maxZoom, placementZoom); + addCollisionBoxVertex(layoutVertexArray, boxAnchorPoint, symbolInstance.anchor, tl, maxZoom, placementZoom); + addCollisionBoxVertex(layoutVertexArray, boxAnchorPoint, symbolInstance.anchor, tr, maxZoom, placementZoom); + addCollisionBoxVertex(layoutVertexArray, boxAnchorPoint, symbolInstance.anchor, br, maxZoom, placementZoom); + addCollisionBoxVertex(layoutVertexArray, boxAnchorPoint, symbolInstance.anchor, bl, maxZoom, placementZoom); elementArray.emplaceBack(index, index + 1); elementArray.emplaceBack(index + 1, index + 2); diff --git a/src/data/feature_index.js b/src/data/feature_index.js index 9a1589e671a..f89ad9b4e22 100644 --- a/src/data/feature_index.js +++ b/src/data/feature_index.js @@ -242,7 +242,7 @@ class FeatureIndex { if (layerResult === undefined) { layerResult = result[layerID] = []; } - layerResult.push(geojsonFeature); + layerResult.push({ featureIndex: index, feature: geojsonFeature }); } } } diff --git a/src/geo/transform.js b/src/geo/transform.js index c51fd979c3b..193224d96fa 100644 --- a/src/geo/transform.js +++ b/src/geo/transform.js @@ -394,6 +394,21 @@ class Transform { return new Float32Array(posMatrix); } + /** + * Calculate the distance from the center of a tile to the camera + * These distances are in view-space dimensions derived from the size of the + * viewport, similar to this.cameraToCenterDistance + * If the tile is dead-center in the viewport, then cameraToTileDistance == cameraToCenterDistance + * + * @param {Tile} tile + */ + cameraToTileDistance(tile: Object) { + const posMatrix = this.calculatePosMatrix(tile.coord, tile.sourceMaxZoom); + const tileCenter = [tile.tileSize / 2, tile.tileSize / 2, 0, 1]; + vec4.transformMat4(tileCenter, tileCenter, posMatrix); + return tileCenter[3]; + } + _constrain() { if (!this.center || !this.width || !this.height || this._constraining) return; diff --git a/src/render/draw_collision_debug.js b/src/render/draw_collision_debug.js index e7b1388eadc..6f29e0e060b 100644 --- a/src/render/draw_collision_debug.js +++ b/src/render/draw_collision_debug.js @@ -7,6 +7,10 @@ function drawCollisionDebug(painter, sourceCache, layer, coords) { gl.enable(gl.STENCIL_TEST); const program = painter.useProgram('collisionBox'); + gl.activeTexture(gl.TEXTURE1); + painter.frameHistory.bind(gl); + gl.uniform1i(program.u_fadetexture, 1); + for (let i = 0; i < coords.length; i++) { const coord = coords[i]; const tile = sourceCache.getTile(coord); @@ -22,7 +26,12 @@ function drawCollisionDebug(painter, sourceCache, layer, coords) { painter.lineWidth(1); gl.uniform1f(program.u_scale, Math.pow(2, painter.transform.zoom - tile.coord.z)); gl.uniform1f(program.u_zoom, painter.transform.zoom * 10); - gl.uniform1f(program.u_maxzoom, (tile.coord.z + 1) * 10); + const maxZoom = Math.max(0, Math.min(25, tile.coord.z + Math.log(tile.collisionTile.maxScale) / Math.LN2)); + gl.uniform1f(program.u_maxzoom, maxZoom * 10); + + gl.uniform1f(program.u_collision_y_stretch, tile.collisionTile.yStretch); + gl.uniform1f(program.u_pitch, painter.transform.pitch / 360 * 2 * Math.PI); + gl.uniform1f(program.u_camera_to_center_distance, painter.transform.cameraToCenterDistance); for (const segment of buffers.segments) { segment.vaos[layer.id].bind(gl, program, buffers.layoutVertexBuffer, buffers.elementBuffer, null, segment.vertexOffset); diff --git a/src/render/draw_symbol.js b/src/render/draw_symbol.js index 042db5c4701..7fd8f95abde 100644 --- a/src/render/draw_symbol.js +++ b/src/render/draw_symbol.js @@ -100,6 +100,8 @@ function drawLayerSymbols(painter, sourceCache, layer, coords, isText, translate gl.uniformMatrix4fv(program.u_matrix, false, painter.translatePosMatrix(coord.posMatrix, tile, translate, translateAnchor)); + gl.uniform1f(program.u_collision_y_stretch, tile.collisionTile.yStretch); + drawTileSymbols(program, programConfiguration, painter, layer, tile, buffers, isText, isSDF, pitchWithMap); @@ -191,6 +193,22 @@ function setSymbolDrawState(program, painter, layer, tileZoom, isText, isSDF, ro } else if (sizeData.isFeatureConstant && sizeData.isZoomConstant) { gl.uniform1f(program.u_size, sizeData.layoutSize); } + gl.uniform1f(program.u_camera_to_center_distance, tr.cameraToCenterDistance); + if (layer.layout['symbol-placement'] === 'line' && + layer.layout['text-rotation-alignment'] === 'map' && + layer.layout['text-pitch-alignment'] === 'viewport' && + layer.layout['text-field']) { + // We hide line labels with viewport alignment as they move into the distance + // because the approximations we use for drawing their glyphs get progressively worse + // The "1.5" here means we start hiding them when the distance from the label + // to the camera is 50% greater than the distance from the center of the map + // to the camera. Depending on viewport properties, you might expect this to filter + // the top third of the screen at pitch 60, and do almost nothing at pitch 45 + gl.uniform1f(program.u_max_camera_distance, 1.5); + } else { + // "10" is effectively infinite at any pitch we support + gl.uniform1f(program.u_max_camera_distance, 10); + } } function drawTileSymbols(program, programConfiguration, painter, layer, tile, buffers, isText, isSDF, pitchWithMap) { diff --git a/src/shaders/collision_box.fragment.glsl b/src/shaders/collision_box.fragment.glsl index 030f1219f8e..aa0e5df0d5f 100644 --- a/src/shaders/collision_box.fragment.glsl +++ b/src/shaders/collision_box.fragment.glsl @@ -1,23 +1,37 @@ uniform float u_zoom; +// u_maxzoom is derived from the maximum scale considered by the CollisionTile +// Labels with placement zoom greater than this value will not be placed, +// regardless of perspective effects. uniform float u_maxzoom; +uniform sampler2D u_fadetexture; +// v_max_zoom is a collision-box-specific value that controls when line-following +// collision boxes are used. varying float v_max_zoom; varying float v_placement_zoom; +varying float v_perspective_zoom_adjust; +varying vec2 v_fade_tex; void main() { float alpha = 0.5; + // Green = no collisions, label is showing gl_FragColor = vec4(0.0, 1.0, 0.0, 1.0) * alpha; - if (v_placement_zoom > u_zoom) { + // Red = collision, label hidden + if (texture2D(u_fadetexture, v_fade_tex).a < 1.0) { gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0) * alpha; } - if (u_zoom >= v_max_zoom) { + // Faded black = this collision box is not used at this zoom (for curved labels) + if (u_zoom >= v_max_zoom + v_perspective_zoom_adjust) { gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0) * alpha * 0.25; } + // Faded blue = the placement scale for this label is beyond the CollisionTile + // max scale, so it's impossible for this label to show without collision detection + // being run again (the label's glyphs haven't even been added to the symbol bucket) if (v_placement_zoom >= u_maxzoom) { gl_FragColor = vec4(0.0, 0.0, 1.0, 1.0) * alpha * 0.2; } diff --git a/src/shaders/collision_box.vertex.glsl b/src/shaders/collision_box.vertex.glsl index d141b13873a..3ae55780341 100644 --- a/src/shaders/collision_box.vertex.glsl +++ b/src/shaders/collision_box.vertex.glsl @@ -1,16 +1,32 @@ attribute vec2 a_pos; +attribute vec2 a_anchor_pos; attribute vec2 a_extrude; attribute vec2 a_data; uniform mat4 u_matrix; uniform float u_scale; +uniform float u_pitch; +uniform float u_collision_y_stretch; +uniform float u_camera_to_center_distance; varying float v_max_zoom; varying float v_placement_zoom; +varying float v_perspective_zoom_adjust; +varying vec2 v_fade_tex; void main() { - gl_Position = u_matrix * vec4(a_pos + a_extrude / u_scale, 0.0, 1.0); + vec4 projectedPoint = u_matrix * vec4(a_anchor_pos, 0, 1); + highp float camera_to_anchor_distance = projectedPoint.w; + highp float collision_perspective_ratio = 1.0 + 0.5 * ((camera_to_anchor_distance / u_camera_to_center_distance) - 1.0); + + highp float incidence_stretch = camera_to_anchor_distance / (u_camera_to_center_distance * cos(u_pitch)); + highp float collision_adjustment = max(1.0, incidence_stretch / u_collision_y_stretch); + + gl_Position = u_matrix * vec4(a_pos + a_extrude * collision_perspective_ratio * collision_adjustment / u_scale, 0.0, 1.0); v_max_zoom = a_data.x; v_placement_zoom = a_data.y; + + v_perspective_zoom_adjust = floor(log2(collision_perspective_ratio * collision_adjustment) * 10.0); + v_fade_tex = vec2((v_placement_zoom + v_perspective_zoom_adjust) / 255.0, 0.0); } diff --git a/src/shaders/symbol_icon.vertex.glsl b/src/shaders/symbol_icon.vertex.glsl index fe2f5f6e20b..2cc973c5613 100644 --- a/src/shaders/symbol_icon.vertex.glsl +++ b/src/shaders/symbol_icon.vertex.glsl @@ -1,14 +1,17 @@ - attribute vec4 a_pos_offset; +attribute vec2 a_label_pos; attribute vec4 a_data; // icon-size data (see symbol_sdf.vertex.glsl for more) attribute vec3 a_size; uniform bool u_is_size_zoom_constant; uniform bool u_is_size_feature_constant; -uniform mediump float u_size_t; // used to interpolate between zoom stops when size is a composite function -uniform mediump float u_size; // used when size is both zoom and feature constant -uniform mediump float u_layout_size; // used when size is feature constant +uniform highp float u_size_t; // used to interpolate between zoom stops when size is a composite function +uniform highp float u_size; // used when size is both zoom and feature constant +uniform highp float u_layout_size; // used when size is feature constant +uniform highp float u_camera_to_center_distance; +uniform highp float u_pitch; +uniform highp float u_collision_y_stretch; #pragma mapbox: define lowp float opacity @@ -16,7 +19,7 @@ uniform mediump float u_layout_size; // used when size is feature constant uniform mat4 u_matrix; uniform bool u_is_text; -uniform mediump float u_zoom; +uniform highp float u_zoom; uniform bool u_rotate_with_map; uniform vec2 u_extrude_scale; @@ -32,11 +35,11 @@ void main() { vec2 a_offset = a_pos_offset.zw; vec2 a_tex = a_data.xy; - mediump vec2 label_data = unpack_float(a_data[2]); - mediump float a_labelminzoom = label_data[0]; - mediump vec2 a_zoom = unpack_float(a_data[3]); - mediump float a_minzoom = a_zoom[0]; - mediump float a_maxzoom = a_zoom[1]; + highp vec2 label_data = unpack_float(a_data[2]); + highp float a_labelminzoom = label_data[0]; + highp vec2 a_zoom = unpack_float(a_data[3]); + highp float a_minzoom = a_zoom[0]; + highp float a_maxzoom = a_zoom[1]; float size; // In order to accommodate placing labels around corners in @@ -47,7 +50,7 @@ void main() { // currently rendered zoom level if text-size is zoom-dependent. // Thus, we compensate for this difference by calculating an adjustment // based on the scale of rendered text size relative to layout text size. - mediump float layoutSize; + highp float layoutSize; if (!u_is_size_zoom_constant && !u_is_size_feature_constant) { size = mix(a_size[0], a_size[1], u_size_t) / 10.0; layoutSize = a_size[2] / 10.0; @@ -64,12 +67,16 @@ void main() { float fontScale = u_is_text ? size / 24.0 : size; - mediump float zoomAdjust = log2(size / layoutSize); - mediump float adjustedZoom = (u_zoom - zoomAdjust) * 10.0; + highp float zoomAdjust = log2(size / layoutSize); + highp float adjustedZoom = (u_zoom - zoomAdjust) * 10.0; // result: z = 0 if a_minzoom <= adjustedZoom < a_maxzoom, and 1 otherwise - mediump float z = 2.0 - step(a_minzoom, adjustedZoom) - (1.0 - step(a_maxzoom, adjustedZoom)); + highp float z = 2.0 - step(a_minzoom, adjustedZoom) - (1.0 - step(a_maxzoom, adjustedZoom)); + + vec4 projectedPoint = u_matrix * vec4(a_label_pos, 0, 1); + highp float camera_to_anchor_distance = projectedPoint.w; + highp float perspective_ratio = 1.0 + 0.5*((camera_to_anchor_distance / u_camera_to_center_distance) - 1.0); - vec2 extrude = fontScale * u_extrude_scale * (a_offset / 64.0); + vec2 extrude = fontScale * u_extrude_scale * perspective_ratio * (a_offset / 64.0); if (u_rotate_with_map) { gl_Position = u_matrix * vec4(a_pos + extrude, 0, 1); gl_Position.z += z * gl_Position.w; @@ -78,5 +85,10 @@ void main() { } v_tex = a_tex / u_texsize; - v_fade_tex = vec2(a_labelminzoom / 255.0, 0.0); + // See comments in symbol_sdf.vertex + highp float incidence_stretch = camera_to_anchor_distance / (u_camera_to_center_distance * cos(u_pitch)); + highp float collision_adjustment = max(1.0, incidence_stretch / u_collision_y_stretch); + + highp float perspective_zoom_adjust = floor(log2(perspective_ratio * collision_adjustment) * 10.0); + v_fade_tex = vec2((a_labelminzoom + perspective_zoom_adjust) / 255.0, 0.0); } diff --git a/src/shaders/symbol_sdf.vertex.glsl b/src/shaders/symbol_sdf.vertex.glsl index 8de5e56492e..cb5d44f19af 100644 --- a/src/shaders/symbol_sdf.vertex.glsl +++ b/src/shaders/symbol_sdf.vertex.glsl @@ -3,6 +3,7 @@ const float PI = 3.141592653589793; // NOTE: the a_data attribute in this shader is manually bound (see https://github.com/mapbox/mapbox-gl-js/issues/4607). // If removing or renaming a_data, revisit the manual binding in painter.js accordingly. attribute vec4 a_pos_offset; +attribute vec2 a_label_pos; attribute vec4 a_data; // contents of a_size vary based on the type of property value @@ -16,9 +17,9 @@ attribute vec4 a_data; attribute vec3 a_size; uniform bool u_is_size_zoom_constant; uniform bool u_is_size_feature_constant; -uniform mediump float u_size_t; // used to interpolate between zoom stops when size is a composite function -uniform mediump float u_size; // used when size is both zoom and feature constant -uniform mediump float u_layout_size; // used when size is feature constant +uniform highp float u_size_t; // used to interpolate between zoom stops when size is a composite function +uniform highp float u_size; // used when size is both zoom and feature constant +uniform highp float u_layout_size; // used when size is feature constant #pragma mapbox: define highp vec4 fill_color #pragma mapbox: define highp vec4 halo_color @@ -30,12 +31,15 @@ uniform mediump float u_layout_size; // used when size is feature constant uniform mat4 u_matrix; uniform bool u_is_text; -uniform mediump float u_zoom; +uniform highp float u_zoom; uniform bool u_rotate_with_map; uniform bool u_pitch_with_map; -uniform mediump float u_pitch; -uniform mediump float u_bearing; -uniform mediump float u_aspect_ratio; +uniform highp float u_pitch; +uniform highp float u_bearing; +uniform highp float u_aspect_ratio; +uniform highp float u_camera_to_center_distance; +uniform highp float u_max_camera_distance; +uniform highp float u_collision_y_stretch; uniform vec2 u_extrude_scale; uniform vec2 u_texsize; @@ -45,6 +49,18 @@ varying vec2 v_fade_tex; varying float v_gamma_scale; varying float v_size; +// Used below to move the vertex out of the clip space for when the current +// zoom is out of the glyph's zoom range. +highp float clipUnusedGlyphAngles(const highp float render_size, + const highp float layout_size, + const highp float min_zoom, + const highp float max_zoom) { + highp float zoom_adjust = log2(render_size / layout_size); + highp float adjusted_zoom = (u_zoom - zoom_adjust) * 10.0; + // result: 0 if min_zoom <= adjusted_zoom < max_zoom, and 1 otherwise + return 2.0 - step(min_zoom, adjusted_zoom) - (1.0 - step(max_zoom, adjusted_zoom)); +} + void main() { #pragma mapbox: initialize highp vec4 fill_color #pragma mapbox: initialize highp vec4 halo_color @@ -57,13 +73,12 @@ void main() { vec2 a_tex = a_data.xy; - mediump vec2 label_data = unpack_float(a_data[2]); - mediump float a_labelminzoom = label_data[0]; - mediump float a_labelangle = label_data[1]; - - mediump vec2 a_zoom = unpack_float(a_data[3]); - mediump float a_minzoom = a_zoom[0]; - mediump float a_maxzoom = a_zoom[1]; + highp vec2 label_data = unpack_float(a_data[2]); + highp float a_labelminzoom = label_data[0]; + highp float a_lineangle = (label_data[1] / 256.0 * 2.0 * PI); + highp vec2 a_zoom = unpack_float(a_data[3]); + highp float a_minzoom = a_zoom[0]; + highp float a_maxzoom = a_zoom[1]; // In order to accommodate placing labels around corners in // symbol-placement: line, each glyph in a label could have multiple @@ -73,7 +88,7 @@ void main() { // currently rendered zoom level if text-size is zoom-dependent. // Thus, we compensate for this difference by calculating an adjustment // based on the scale of rendered text size relative to layout text size. - mediump float layoutSize; + highp float layoutSize; if (!u_is_size_zoom_constant && !u_is_size_feature_constant) { v_size = mix(a_size[0], a_size[1], u_size_t) / 10.0; layoutSize = a_size[2] / 10.0; @@ -90,57 +105,78 @@ void main() { float fontScale = u_is_text ? v_size / 24.0 : v_size; - mediump float zoomAdjust = log2(v_size / layoutSize); - mediump float adjustedZoom = (u_zoom - zoomAdjust) * 10.0; - // result: z = 0 if a_minzoom <= adjustedZoom < a_maxzoom, and 1 otherwise - // Used below to move the vertex out of the clip space for when the current - // zoom is out of the glyph's zoom range. - mediump float z = 2.0 - step(a_minzoom, adjustedZoom) - (1.0 - step(a_maxzoom, adjustedZoom)); + vec4 projectedPoint = u_matrix * vec4(a_label_pos, 0, 1); + highp float camera_to_anchor_distance = projectedPoint.w; + highp float perspective_ratio = 1.0 + 0.5*((camera_to_anchor_distance / u_camera_to_center_distance) - 1.0); // pitch-alignment: map // rotation-alignment: map | viewport if (u_pitch_with_map) { - lowp float angle = u_rotate_with_map ? (a_labelangle / 256.0 * 2.0 * PI) : u_bearing; - lowp float asin = sin(angle); - lowp float acos = cos(angle); + highp float angle = u_rotate_with_map ? a_lineangle : u_bearing; + highp float asin = sin(angle); + highp float acos = cos(angle); mat2 RotationMatrix = mat2(acos, asin, -1.0 * asin, acos); vec2 offset = RotationMatrix * a_offset; - vec2 extrude = fontScale * u_extrude_scale * (offset / 64.0); + vec2 extrude = fontScale * u_extrude_scale * perspective_ratio * (offset / 64.0); + gl_Position = u_matrix * vec4(a_pos + extrude, 0, 1); - gl_Position.z += z * gl_Position.w; + gl_Position.z += clipUnusedGlyphAngles(v_size*perspective_ratio, layoutSize, a_minzoom, a_maxzoom) * gl_Position.w; // pitch-alignment: viewport // rotation-alignment: map } else if (u_rotate_with_map) { // foreshortening factor to apply on pitched maps // as a label goes from horizontal <=> vertical in angle // it goes from 0% foreshortening to up to around 70% foreshortening - lowp float pitchfactor = 1.0 - cos(u_pitch * sin(u_pitch * 0.75)); - - lowp float lineangle = a_labelangle / 256.0 * 2.0 * PI; + highp float pitchfactor = 1.0 - cos(u_pitch * sin(u_pitch * 0.75)); // use the lineangle to position points a,b along the line // project the points and calculate the label angle in projected space // this calculation allows labels to be rendered unskewed on pitched maps vec4 a = u_matrix * vec4(a_pos, 0, 1); - vec4 b = u_matrix * vec4(a_pos + vec2(cos(lineangle),sin(lineangle)), 0, 1); - lowp float angle = atan((b[1]/b[3] - a[1]/a[3])/u_aspect_ratio, b[0]/b[3] - a[0]/a[3]); - lowp float asin = sin(angle); - lowp float acos = cos(angle); + vec4 b = u_matrix * vec4(a_pos + vec2(cos(a_lineangle), sin(a_lineangle)), 0, 1); + highp float angle = atan((b[1] / b[3] - a[1] / a[3]) / u_aspect_ratio, b[0] / b[3] - a[0] / a[3]); + highp float asin = sin(angle); + highp float acos = cos(angle); mat2 RotationMatrix = mat2(acos, -1.0 * asin, asin, acos); + highp float foreshortening = (1.0 - pitchfactor) + (pitchfactor * cos(angle * 2.0)); + + vec2 offset = RotationMatrix * (vec2(foreshortening, 1.0) * a_offset); + vec2 extrude = fontScale * u_extrude_scale * perspective_ratio * (offset / 64.0); - vec2 offset = RotationMatrix * (vec2((1.0-pitchfactor)+(pitchfactor*cos(angle*2.0)), 1.0) * a_offset); - vec2 extrude = fontScale * u_extrude_scale * (offset / 64.0); gl_Position = u_matrix * vec4(a_pos, 0, 1) + vec4(extrude, 0, 0); - gl_Position.z += z * gl_Position.w; + gl_Position.z += clipUnusedGlyphAngles(v_size * perspective_ratio, layoutSize, a_minzoom, a_maxzoom) * gl_Position.w; // pitch-alignment: viewport // rotation-alignment: viewport } else { - vec2 extrude = fontScale * u_extrude_scale * (a_offset / 64.0); + vec2 extrude = fontScale * u_extrude_scale * perspective_ratio * (a_offset / 64.0); gl_Position = u_matrix * vec4(a_pos, 0, 1) + vec4(extrude, 0, 0); } - v_gamma_scale = gl_Position.w; + gl_Position.z += + step(u_max_camera_distance * u_camera_to_center_distance, camera_to_anchor_distance) * gl_Position.w; + + v_gamma_scale = gl_Position.w / perspective_ratio; v_tex = a_tex / u_texsize; - v_fade_tex = vec2(a_labelminzoom / 255.0, 0.0); + // incidence_stretch is the ratio of how much y space a label takes up on a tile while drawn perpendicular to the viewport vs + // how much space it would take up if it were drawn flat on the tile + // Using law of sines, camera_to_anchor/sin(ground_angle) = camera_to_center/sin(incidence_angle) + // sin(incidence_angle) = 1/incidence_stretch + // Incidence angle 90 -> head on, sin(incidence_angle) = 1, no incidence stretch + // Incidence angle 1 -> very oblique, sin(incidence_angle) =~ 0, lots of incidence stretch + // ground_angle = u_pitch + PI/2 -> sin(ground_angle) = cos(u_pitch) + // This 2D calculation is only exactly correct when gl_Position.x is in the center of the viewport, + // but it's a close enough approximation for our purposes + highp float incidence_stretch = camera_to_anchor_distance / (u_camera_to_center_distance * cos(u_pitch)); + // incidence_stretch only applies to the y-axis, but without re-calculating the collision tile, we can't + // adjust the size of only one axis. So, we do a crude approximation at placement time to get the aspect ratio + // about right, and then do the rest of the adjustment here: there will be some extra padding on the x-axis, + // but hopefully not too much. + // Never make the adjustment less than 1.0: instead of allowing collisions on the x-axis, be conservative on + // the y-axis. + highp float collision_adjustment = max(1.0, incidence_stretch / u_collision_y_stretch); + + // Floor to 1/10th zoom to dodge precision issues that can cause partially hidden labels + highp float perspective_zoom_adjust = floor(log2(perspective_ratio * collision_adjustment) * 10.0); + v_fade_tex = vec2((a_labelminzoom + perspective_zoom_adjust) / 255.0, 0.0); } diff --git a/src/source/canvas_source.js b/src/source/canvas_source.js index 8439b09b657..e797ba3439e 100644 --- a/src/source/canvas_source.js +++ b/src/source/canvas_source.js @@ -102,7 +102,7 @@ class CanvasSource extends ImageSource { } if (this._hasInvalidDimensions()) return; - if (!this.tile) return; // not enough data for current position + if (Object.keys(this.tiles).length === 0) return; // not enough data for current position this._prepareImage(this.map.painter.gl, this.canvas, resize); } diff --git a/src/source/geojson_source.js b/src/source/geojson_source.js index 7517812cd4c..6eaa669e9a8 100644 --- a/src/source/geojson_source.js +++ b/src/source/geojson_source.js @@ -175,6 +175,8 @@ class GeoJSONSource extends Evented { overscaling: overscaling, angle: this.map.transform.angle, pitch: this.map.transform.pitch, + cameraToCenterDistance: this.map.transform.cameraToCenterDistance, + cameraToTileDistance: this.map.transform.cameraToTileDistance(tile), showCollisionBoxes: this.map.showCollisionBoxes }; diff --git a/src/source/image_source.js b/src/source/image_source.js index 6946595d3bc..2de718c8905 100644 --- a/src/source/image_source.js +++ b/src/source/image_source.js @@ -54,10 +54,12 @@ class ImageSource extends Evented { this.minzoom = 0; this.maxzoom = 22; this.tileSize = 512; + this.tiles = {}; this.setEventedParent(eventedParent); this.options = options; + this.textureLoaded = false; } load() { @@ -140,7 +142,7 @@ class ImageSource extends Evented { } _setTile(tile) { - this.tile = tile; + this.tiles[tile.coord.w] = tile; const maxInt16 = 32767; const array = new RasterBoundsArray(); array.emplaceBack(this._tileCoords[0].x, this._tileCoords[0].y, 0, 0); @@ -148,22 +150,22 @@ class ImageSource extends Evented { array.emplaceBack(this._tileCoords[3].x, this._tileCoords[3].y, 0, maxInt16); array.emplaceBack(this._tileCoords[2].x, this._tileCoords[2].y, maxInt16, maxInt16); - this.tile.buckets = {}; + tile.buckets = {}; - this.tile.boundsBuffer = Buffer.fromStructArray(array, Buffer.BufferType.VERTEX); - this.tile.boundsVAO = new VertexArrayObject(); + tile.boundsBuffer = Buffer.fromStructArray(array, Buffer.BufferType.VERTEX); + tile.boundsVAO = new VertexArrayObject(); } prepare() { - if (!this.tile || !this.image) return; + if (Object.keys(this.tiles).length === 0 === 0 || !this.image) return; this._prepareImage(this.map.painter.gl, this.image); } _prepareImage(gl, image, resize) { - if (this.tile.state !== 'loaded') { - this.tile.state = 'loaded'; - this.tile.texture = gl.createTexture(); - gl.bindTexture(gl.TEXTURE_2D, this.tile.texture); + if (!this.textureLoaded) { + this.textureLoaded = true; + this.texture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, this.texture); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); @@ -172,9 +174,17 @@ class ImageSource extends Evented { } else if (resize) { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image); } else if (image instanceof window.HTMLVideoElement || image instanceof window.ImageData || image instanceof window.HTMLCanvasElement) { - gl.bindTexture(gl.TEXTURE_2D, this.tile.texture); + gl.bindTexture(gl.TEXTURE_2D, this.texture); gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, image); } + + for (const w in this.tiles) { + const tile = this.tiles[w]; + if (tile.state !== 'loaded') { + tile.state = 'loaded'; + tile.texture = this.texture; + } + } } loadTile(tile, callback) { @@ -182,6 +192,8 @@ class ImageSource extends Evented { // covers the image we want to render. If that's the one being // requested, set it up with the image; otherwise, mark the tile as // `errored` to indicate that we have no data for it. + // If the world wraps, we may have multiple "wrapped" copies of the + // single tile. if (this.coord && this.coord.toString() === tile.coord.toString()) { this._setTile(tile); callback(null); diff --git a/src/source/query_features.js b/src/source/query_features.js index 473492f0c2e..9fae1ca590d 100644 --- a/src/source/query_features.js +++ b/src/source/query_features.js @@ -11,13 +11,15 @@ exports.rendered = function(sourceCache, styleLayers, queryGeometry, params, zoo const tileIn = tilesIn[r]; if (!tileIn.tile.featureIndex) continue; - renderedFeatureLayers.push(tileIn.tile.featureIndex.query({ - queryGeometry: tileIn.queryGeometry, - scale: tileIn.scale, - tileSize: tileIn.tile.tileSize, - bearing: bearing, - params: params - }, styleLayers)); + renderedFeatureLayers.push({ + wrappedTileID: tileIn.coord.wrapped().id, + queryResults: tileIn.tile.featureIndex.query({ + queryGeometry: tileIn.queryGeometry, + scale: tileIn.scale, + tileSize: tileIn.tile.tileSize, + bearing: bearing, + params: params + }, styleLayers)}); } return mergeRenderedFeatureLayers(renderedFeatureLayers); }; @@ -49,21 +51,25 @@ function sortTilesIn(a, b) { } function mergeRenderedFeatureLayers(tiles) { - const result = tiles[0] || {}; - for (let i = 1; i < tiles.length; i++) { - const tile = tiles[i]; - for (const layerID in tile) { - const tileFeatures = tile[layerID]; - let resultFeatures = result[layerID]; - if (resultFeatures === undefined) { - resultFeatures = result[layerID] = tileFeatures; - } else { - for (let f = 0; f < tileFeatures.length; f++) { - resultFeatures.push(tileFeatures[f]); + // Merge results from all tiles, but if two tiles share the same + // wrapped ID, don't duplicate features between the two tiles + const result = {}; + const wrappedIDLayerMap = {}; + for (const tile of tiles) { + const queryResults = tile.queryResults; + const wrappedID = tile.wrappedTileID; + const wrappedIDLayers = wrappedIDLayerMap[wrappedID] = wrappedIDLayerMap[wrappedID] || {}; + for (const layerID in queryResults) { + const tileFeatures = queryResults[layerID]; + const wrappedIDFeatures = wrappedIDLayers[layerID] = wrappedIDLayers[layerID] || {}; + const resultFeatures = result[layerID] = result[layerID] || []; + for (const tileFeature of tileFeatures) { + if (!wrappedIDFeatures[tileFeature.featureIndex]) { + wrappedIDFeatures[tileFeature.featureIndex] = true; + resultFeatures.push(tileFeature.feature); } } } } return result; } - diff --git a/src/source/source_cache.js b/src/source/source_cache.js index b09075b659a..4ab8b4745e3 100644 --- a/src/source/source_cache.js +++ b/src/source/source_cache.js @@ -213,7 +213,7 @@ class SourceCache extends Evented { * Recursively find children of the given tile (up to maxCoveringZoom) that are already loaded; * adds found tiles to retain object; returns true if any child is found. * - * @param {Coordinate} coord + * @param {TileCoord} coord * @param {number} maxCoveringZoom * @param {boolean} retain * @returns {boolean} whether the operation was complete @@ -256,7 +256,7 @@ class SourceCache extends Evented { * Find a loaded parent of the given tile (up to minCoveringZoom); * adds the found tile to retain object and returns the tile if found * - * @param {Coordinate} coord + * @param {TileCoord} coord * @param {number} minCoveringZoom * @param {boolean} retain * @returns {Tile} tile object @@ -407,39 +407,35 @@ class SourceCache extends Evented { /** * Add a tile, given its coordinate, to the pyramid. - * @param {Coordinate} coord - * @returns {Coordinate} the coordinate. + * @param {TileCoord} tileCoord + * @returns {Tile} the added Tile. * @private */ - addTile(coord) { - let tile = this._tiles[coord.id]; + addTile(tileCoord) { + let tile = this._tiles[tileCoord.id]; if (tile) return tile; - const wrapped = coord.wrapped(); - tile = this._tiles[wrapped.id]; - - if (!tile) { - tile = this._cache.get(wrapped.id); - if (tile) { - tile.redoPlacement(this._source); - if (this._cacheTimers[wrapped.id]) { - clearTimeout(this._cacheTimers[wrapped.id]); - this._cacheTimers[wrapped.id] = undefined; - this._setTileReloadTimer(wrapped.id, tile); - } + tile = this._cache.get(tileCoord.id); + if (tile) { + tile.redoPlacement(this._source); + if (this._cacheTimers[tileCoord.id]) { + clearTimeout(this._cacheTimers[tileCoord.id]); + this._cacheTimers[tileCoord.id] = undefined; + this._setTileReloadTimer(tileCoord.id, tile); } } + const cached = Boolean(tile); if (!cached) { - const zoom = coord.z; + const zoom = tileCoord.z; const overscaling = zoom > this._source.maxzoom ? Math.pow(2, zoom - this._source.maxzoom) : 1; - tile = new Tile(wrapped, this._source.tileSize * overscaling, this._source.maxzoom); - this.loadTile(tile, this._tileLoaded.bind(this, tile, coord.id, tile.state)); + tile = new Tile(tileCoord, this._source.tileSize * overscaling, this._source.maxzoom); + this.loadTile(tile, this._tileLoaded.bind(this, tile, tileCoord.id, tile.state)); } tile.uses++; - this._tiles[coord.id] = tile; + this._tiles[tileCoord.id] = tile; if (!cached) this._source.fire('dataloading', {tile: tile, coord: tile.coord, dataType: 'source'}); return tile; @@ -486,6 +482,8 @@ class SourceCache extends Evented { if (tile.uses > 0) return; + tile.stopPlacementThrottler(); + if (tile.hasData()) { const wrappedId = tile.coord.wrapped().id; this._cache.add(wrappedId, tile); @@ -515,7 +513,7 @@ class SourceCache extends Evented { * @private */ tilesIn(queryGeometry) { - const tileResults = {}; + const tileResults = []; const ids = this.getIds(); let minX = Infinity; @@ -532,6 +530,7 @@ class SourceCache extends Evented { maxY = Math.max(maxY, p.row); } + for (let i = 0; i < ids.length; i++) { const tile = this._tiles[ids[i]]; const coord = TileCoord.fromID(ids[i]); @@ -549,26 +548,16 @@ class SourceCache extends Evented { tileSpaceQueryGeometry.push(coordinateToTilePoint(coord, tile.sourceMaxZoom, queryGeometry[j])); } - let tileResult = tileResults[tile.coord.id]; - if (tileResult === undefined) { - tileResult = tileResults[tile.coord.id] = { - tile: tile, - coord: coord, - queryGeometry: [], - scale: Math.pow(2, this.transform.zoom - tile.coord.z) - }; - } - - // Wrapped tiles share one tileResult object but can have multiple queryGeometry parts - tileResult.queryGeometry.push(tileSpaceQueryGeometry); + tileResults.push({ + tile: tile, + coord: coord, + queryGeometry: [tileSpaceQueryGeometry], + scale: Math.pow(2, this.transform.zoom - tile.coord.z) + }); } } - const results = []; - for (const t in tileResults) { - results.push(tileResults[t]); - } - return results; + return tileResults; } redoPlacement() { @@ -593,7 +582,8 @@ SourceCache.maxUnderzooming = 3; /** * Convert a coordinate to a point in a tile's coordinate space. - * @param {Coordinate} tileCoord + * @param {TileCoord} tileCoord + * @param {number} sourceMaxZoom * @param {Coordinate} coord * @returns {Object} position * @private diff --git a/src/source/tile.js b/src/source/tile.js index 2330db6fc06..be9110a14ee 100644 --- a/src/source/tile.js +++ b/src/source/tile.js @@ -9,6 +9,7 @@ const GeoJSONFeature = require('../util/vectortile_to_geojson'); const featureFilter = require('../style-spec/feature_filter'); const CollisionTile = require('../symbol/collision_tile'); const CollisionBoxArray = require('../symbol/collision_box'); +const Throttler = require('../util/throttler'); const CLOCK_SKEW_RETRY_TIMEOUT = 30000; @@ -20,7 +21,7 @@ const CLOCK_SKEW_RETRY_TIMEOUT = 30000; */ class Tile { /** - * @param {Coordinate} coord + * @param {TileCoord} coord * @param {number} size */ constructor(coord, size, sourceMaxZoom) { @@ -47,6 +48,8 @@ class Tile { // - `errored`: Tile data was not loaded because of an error. // - `expired`: Tile data was previously loaded, but has expired per its HTTP headers and is in the process of refreshing. this.state = 'loading'; + + this.placementThrottler = new Throttler(300, this._immediateRedoPlacement.bind(this)); } registerFadeDuration(animationLoop, duration) { @@ -144,26 +147,56 @@ class Tile { return; } + const cameraToTileDistance = source.map.transform.cameraToTileDistance(this); + if (this.angle === source.map.transform.angle && + this.pitch === source.map.transform.pitch && + this.cameraToCenterDistance === source.map.transform.cameraToCenterDistance && + this.showCollisionBoxes === source.map.showCollisionBoxes) { + if (this.cameraToTileDistance === cameraToTileDistance) { + return; + } else if (this.pitch < 25) { + // At low pitch tile distance doesn't affect placement very + // much, so we skip the cost of redoPlacement + // However, we might as well store the latest value of + // cameraToTileDistance in case a redoPlacement request + // is already queued. + this.cameraToTileDistance = cameraToTileDistance; + return; + } + } + + this.angle = source.map.transform.angle; + this.pitch = source.map.transform.pitch; + this.cameraToCenterDistance = source.map.transform.cameraToCenterDistance; + this.cameraToTileDistance = cameraToTileDistance; + this.showCollisionBoxes = source.map.showCollisionBoxes; + this.placementSource = source; + this.state = 'reloading'; + this.placementThrottler.invoke(); + } - source.dispatcher.send('redoPlacement', { - type: source.type, + _immediateRedoPlacement() { + this.placementSource.dispatcher.send('redoPlacement', { + type: this.placementSource.type, uid: this.uid, - source: source.id, - angle: source.map.transform.angle, - pitch: source.map.transform.pitch, - showCollisionBoxes: source.map.showCollisionBoxes + source: this.placementSource.id, + angle: this.angle, + pitch: this.pitch, + cameraToCenterDistance: this.cameraToCenterDistance, + cameraToTileDistance: this.cameraToTileDistance, + showCollisionBoxes: this.showCollisionBoxes }, (_, data) => { - this.reloadSymbolData(data, source.map.style); - if (source.map.showCollisionBoxes) source.fire('data', {tile: this, coord: this.coord, dataType: 'source'}); + this.reloadSymbolData(data, this.placementSource.map.style); + if (this.placementSource.map.showCollisionBoxes) this.placementSource.fire('data', {tile: this, coord: this.coord, dataType: 'source'}); // HACK this is nescessary to fix https://github.com/mapbox/mapbox-gl-js/issues/2986 - if (source.map) source.map.painter.tileExtentVAO.vao = null; + if (this.placementSource.map) this.placementSource.map.painter.tileExtentVAO.vao = null; this.state = 'loaded'; if (this.redoWhenDone) { this.redoWhenDone = false; - this.redoPlacement(source); + this._immediateRedoPlacement(); } }, this.workerID); } @@ -259,6 +292,13 @@ class Tile { } } } + + stopPlacementThrottler() { + this.placementThrottler.stop(); + if (this.state === 'reloading') { + this.state = 'loaded'; + } + } } module.exports = Tile; diff --git a/src/source/vector_tile_source.js b/src/source/vector_tile_source.js index c554b1ad901..b81e4622dd2 100644 --- a/src/source/vector_tile_source.js +++ b/src/source/vector_tile_source.js @@ -84,6 +84,8 @@ class VectorTileSource extends Evented { overscaling: overscaling, angle: this.map.transform.angle, pitch: this.map.transform.pitch, + cameraToCenterDistance: this.map.transform.cameraToCenterDistance, + cameraToTileDistance: this.map.transform.cameraToTileDistance(tile), showCollisionBoxes: this.map.showCollisionBoxes }; diff --git a/src/source/vector_tile_worker_source.js b/src/source/vector_tile_worker_source.js index c0c4ffe30c5..23c3d008b4f 100644 --- a/src/source/vector_tile_worker_source.js +++ b/src/source/vector_tile_worker_source.js @@ -39,6 +39,8 @@ class VectorTileWorkerSource { * @param {number} params.overscaling * @param {number} params.angle * @param {number} params.pitch + * @param {number} params.cameraToCenterDistance + * @param {number} params.cameraToTileDistance * @param {boolean} params.showCollisionBoxes */ loadTile(params, callback) { @@ -184,7 +186,7 @@ class VectorTileWorkerSource { if (loaded && loaded[uid]) { const workerTile = loaded[uid]; - const result = workerTile.redoPlacement(params.angle, params.pitch, params.showCollisionBoxes); + const result = workerTile.redoPlacement(params.angle, params.pitch, params.cameraToCenterDistance, params.cameraToTileDistance, params.showCollisionBoxes); if (result.result) { callback(null, result.result, result.transferables); diff --git a/src/source/video_source.js b/src/source/video_source.js index b1155c727cc..5e4ee61fbf5 100644 --- a/src/source/video_source.js +++ b/src/source/video_source.js @@ -107,7 +107,7 @@ class VideoSource extends ImageSource { // setCoordinates inherited from ImageSource prepare() { - if (!this.tile || this.video.readyState < 2) return; // not enough data for current position + if (Object.keys(this.tiles).length === 0 || this.video.readyState < 2) return; // not enough data for current position this._prepareImage(this.map.painter.gl, this.video); } diff --git a/src/source/worker_tile.js b/src/source/worker_tile.js index b91421f9ce8..2a0b662e707 100644 --- a/src/source/worker_tile.js +++ b/src/source/worker_tile.js @@ -17,6 +17,8 @@ class WorkerTile { this.overscaling = params.overscaling; this.angle = params.angle; this.pitch = params.pitch; + this.cameraToCenterDistance = params.cameraToCenterDistance; + this.cameraToTileDistance = params.cameraToTileDistance; this.showCollisionBoxes = params.showCollisionBoxes; } @@ -126,7 +128,7 @@ class WorkerTile { } if (this.symbolBuckets.length === 0) { - return done(new CollisionTile(this.angle, this.pitch, this.collisionBoxArray)); + return done(new CollisionTile(this.angle, this.pitch, this.cameraToCenterDistance, this.cameraToTileDistance, this.collisionBoxArray)); } let deps = 0; @@ -137,7 +139,11 @@ class WorkerTile { if (err) return callback(err); deps++; if (deps === 2) { - const collisionTile = new CollisionTile(this.angle, this.pitch, this.collisionBoxArray); + const collisionTile = new CollisionTile(this.angle, + this.pitch, + this.cameraToCenterDistance, + this.cameraToTileDistance, + this.collisionBoxArray); for (const bucket of this.symbolBuckets) { recalculateLayers(bucket, this.zoom); @@ -169,15 +175,21 @@ class WorkerTile { } } - redoPlacement(angle, pitch, showCollisionBoxes) { + redoPlacement(angle, pitch, cameraToCenterDistance, cameraToTileDistance, showCollisionBoxes) { this.angle = angle; this.pitch = pitch; + this.cameraToCenterDistance = cameraToCenterDistance; + this.cameraToTileDistance = cameraToTileDistance; if (this.status !== 'done') { return {}; } - const collisionTile = new CollisionTile(this.angle, this.pitch, this.collisionBoxArray); + const collisionTile = new CollisionTile(this.angle, + this.pitch, + this.cameraToCenterDistance, + this.cameraToTileDistance, + this.collisionBoxArray); for (const bucket of this.symbolBuckets) { recalculateLayers(bucket, this.zoom); diff --git a/src/symbol/collision_feature.js b/src/symbol/collision_feature.js index b9be1f03718..bbc42d66765 100644 --- a/src/symbol/collision_feature.js +++ b/src/symbol/collision_feature.js @@ -72,34 +72,52 @@ class CollisionFeature { _addLineCollisionBoxes(collisionBoxArray, line, anchor, segment, labelLength, boxSize, featureIndex, sourceLayerIndex, bucketIndex) { const step = boxSize / 2; const nBoxes = Math.floor(labelLength / step); + // We calculate line collision boxes out to 150% of what would normally be our + // max size, to allow collision detection to work on labels that expand as + // they move into the distance + const nPitchPaddingBoxes = Math.floor(nBoxes / 4); // offset the center of the first box by half a box so that the edge of the // box is at the edge of the label. const firstBoxOffset = -boxSize / 2; - const bboxes = this.boxes; - let p = anchor; let index = segment + 1; let anchorDistance = firstBoxOffset; + const labelStartDistance = -labelLength / 2; + const paddingStartDistance = labelStartDistance - labelLength / 8; // move backwards along the line to the first segment the label appears on do { index--; - // there isn't enough room for the label after the beginning of the line - // checkMaxAngle should have already caught this - if (index < 0) return bboxes; - - anchorDistance -= line[index].dist(p); - p = line[index]; - } while (anchorDistance > -labelLength / 2); + if (index < 0) { + if (anchorDistance > labelStartDistance) { + // there isn't enough room for the label after the beginning of the line + // checkMaxAngle should have already caught this + return; + } else { + // The line doesn't extend far enough back for all of our padding, + // but we got far enough to show the label under most conditions. + index = 0; + break; + } + } else { + anchorDistance -= line[index].dist(p); + p = line[index]; + } + } while (anchorDistance > paddingStartDistance); let segmentLength = line[index].dist(line[index + 1]); - for (let i = 0; i < nBoxes; i++) { + for (let i = -nPitchPaddingBoxes; i < nBoxes + nPitchPaddingBoxes; i++) { // the distance the box will be from the anchor - const boxDistanceToAnchor = -labelLength / 2 + i * step; + const boxDistanceToAnchor = labelStartDistance + i * step; + if (boxDistanceToAnchor < anchorDistance) { + // The line doesn't extend far enough back for this box, skip it + // (This could allow for line collisions on distant tiles) + continue; + } // the box is not on the current segment. Move to the next segment. while (anchorDistance + segmentLength < boxDistanceToAnchor) { @@ -107,7 +125,7 @@ class CollisionFeature { index++; // There isn't enough room before the end of the line. - if (index + 1 >= line.length) return bboxes; + if (index + 1 >= line.length) return; segmentLength = line[index].dist(line[index + 1]); } @@ -119,16 +137,35 @@ class CollisionFeature { const p1 = line[index + 1]; const boxAnchorPoint = p1.sub(p0)._unit()._mult(segmentBoxDistance)._add(p0)._round(); + // Distance from label anchor point to inner (towards center) edge of this box + // The tricky thing here is that box positioning doesn't change with scale, + // but box size does change with scale. + // Technically, distanceToInnerEdge should be: + // Math.max(Math.abs(boxDistanceToAnchor - firstBoxOffset) - (step / scale), 0); + // But using that formula would make solving for maxScale more difficult, so we + // approximate with scale=2. + // This makes our calculation spot-on at scale=2, and on the conservative side for + // lower scales const distanceToInnerEdge = Math.max(Math.abs(boxDistanceToAnchor - firstBoxOffset) - step / 2, 0); - const maxScale = labelLength / 2 / distanceToInnerEdge; + let maxScale = labelLength / 2 / distanceToInnerEdge; + + // The box maxScale calculations are designed to be conservative on collisions in the scale range + // [1,2]. At scale=1, each box has 50% overlap, and at scale=2, the boxes are lined up edge + // to edge (beyond scale 2, gaps start to appear, which could potentially allow missed collisions). + // We add "pitch padding" boxes to the left and right to handle effective underzooming + // (scale < 1) when labels are in the distance. The overlap approximation could cause us to use + // these boxes when the scale is greater than 1, but we prevent that because we know + // they're only necessary for scales less than one. + // This preserves the pre-pitch-padding behavior for unpitched maps. + if (i < 0 || i >= nBoxes) { + maxScale = Math.min(maxScale, 0.99); + } collisionBoxArray.emplaceBack(boxAnchorPoint.x, boxAnchorPoint.y, -boxSize / 2, -boxSize / 2, boxSize / 2, boxSize / 2, maxScale, featureIndex, sourceLayerIndex, bucketIndex, 0, 0, 0, 0, 0); } - - return bboxes; } } diff --git a/src/symbol/collision_tile.js b/src/symbol/collision_tile.js index 91ec367668b..705971b6b10 100644 --- a/src/symbol/collision_tile.js +++ b/src/symbol/collision_tile.js @@ -14,12 +14,14 @@ const intersectionTests = require('../util/intersection_tests'); * @private */ class CollisionTile { - constructor(angle, pitch, collisionBoxArray) { + constructor(angle, pitch, cameraToCenterDistance, cameraToTileDistance, collisionBoxArray) { if (typeof angle === 'object') { const serialized = angle; collisionBoxArray = pitch; angle = serialized.angle; pitch = serialized.pitch; + cameraToCenterDistance = serialized.cameraToCenterDistance; + cameraToTileDistance = serialized.cameraToTileDistance; this.grid = new Grid(serialized.grid); this.ignoredGrid = new Grid(serialized.ignoredGrid); } else { @@ -27,11 +29,18 @@ class CollisionTile { this.ignoredGrid = new Grid(EXTENT, 12, 0); } - this.minScale = 0.5; - this.maxScale = 2; + this.perspectiveRatio = 1 + 0.5 * ((cameraToTileDistance / cameraToCenterDistance) - 1); + + // High perspective ratio means we're effectively "underzooming" + // the tile. Adjust the minScale and maxScale range accordingly + // to constrain the number of collision calculations + this.minScale = .5 / this.perspectiveRatio; + this.maxScale = 2 / this.perspectiveRatio; this.angle = angle; this.pitch = pitch; + this.cameraToCenterDistance = cameraToCenterDistance; + this.cameraToTileDistance = cameraToTileDistance; const sin = Math.sin(angle), cos = Math.cos(angle); @@ -39,11 +48,14 @@ class CollisionTile { this.reverseRotationMatrix = [cos, sin, -sin, cos]; // Stretch boxes in y direction to account for the map tilt. - this.yStretch = 1 / Math.cos(pitch / 180 * Math.PI); - // The amount the map is squished depends on the y position. - // Sort of account for this by making all boxes a bit bigger. - this.yStretch = Math.pow(this.yStretch, 1.3); + // We can only approximate here based on the y position of the tile + // The shaders calculate a more accurate "incidence_stretch" + // at render time to calculate an effective scale for collision + // purposes, but we still want to use the yStretch approximation + // here because we can't adjust the aspect ratio of the collision + // boxes at render time. + this.yStretch = Math.max(1, cameraToTileDistance / (cameraToCenterDistance * Math.cos(pitch / 180 * Math.PI))); this.collisionBoxArray = collisionBoxArray; if (collisionBoxArray.length === 0) { @@ -90,6 +102,8 @@ class CollisionTile { return { angle: this.angle, pitch: this.pitch, + cameraToCenterDistance: this.cameraToCenterDistance, + cameraToTileDistance: this.cameraToTileDistance, grid: grid, ignoredGrid: ignoredGrid }; @@ -118,10 +132,18 @@ class CollisionTile { const x = anchorPoint.x; const y = anchorPoint.y; - const x1 = x + box.x1; - const y1 = y + box.y1 * yStretch; - const x2 = x + box.x2; - const y2 = y + box.y2 * yStretch; + // When the 'perspectiveRatio' is high, we're effectively underzooming + // the tile because it's in the distance. + // In order to detect collisions that only happen while underzoomed, + // we have to query a larger portion of the grid. + // This extra work is offset by having a lower 'maxScale' bound + // Note that this adjustment ONLY affects the bounding boxes + // in the grid. It doesn't affect the boxes used for the + // minPlacementScale calculations. + const x1 = x + box.x1 * this.perspectiveRatio; + const y1 = y + box.y1 * yStretch * this.perspectiveRatio; + const x2 = x + box.x2 * this.perspectiveRatio; + const y2 = y + box.y2 * yStretch * this.perspectiveRatio; box.bbox0 = x1; box.bbox1 = y1; @@ -181,7 +203,7 @@ class CollisionTile { const sourceLayerFeatures = {}; const result = []; - if (queryGeometry.length === 0 || (this.grid.length === 0 && this.ignoredGrid.length === 0)) { + if (queryGeometry.length === 0 || (this.grid.keys.length === 0 && this.ignoredGrid.keys.length === 0)) { return result; } @@ -304,14 +326,16 @@ class CollisionTile { * @private */ insertCollisionFeature(collisionFeature, minPlacementScale, ignorePlacement) { - const grid = ignorePlacement ? this.ignoredGrid : this.grid; const collisionBoxArray = this.collisionBoxArray; for (let k = collisionFeature.boxStartIndex; k < collisionFeature.boxEndIndex; k++) { const box = collisionBoxArray.get(k); box.placementScale = minPlacementScale; - if (minPlacementScale < this.maxScale) { + if (minPlacementScale < this.maxScale && + (this.perspectiveRatio === 1 || box.maxScale >= 1)) { + // Boxes with maxScale < 1 are only relevant in pitched maps, + // so filter them out in unpitched maps to keep the grid sparse grid.insert(k, box.bbox0, box.bbox1, box.bbox2, box.bbox3); } } diff --git a/src/symbol/quads.js b/src/symbol/quads.js index 8b745b6c146..62e244bd56c 100644 --- a/src/symbol/quads.js +++ b/src/symbol/quads.js @@ -159,6 +159,8 @@ function getGlyphQuads(anchor, shaping, boxScale, line, layer, alongLine, global const positionedGlyphs = shaping.positionedGlyphs; const quads = []; + let labelMinScale = minScale; + for (let k = 0; k < positionedGlyphs.length; k++) { const positionedGlyph = positionedGlyphs[k]; const glyph = positionedGlyph.glyph; @@ -170,12 +172,11 @@ function getGlyphQuads(anchor, shaping, boxScale, line, layer, alongLine, global const centerX = (positionedGlyph.x + glyph.advance / 2) * boxScale; let glyphInstances; - let labelMinScale = minScale; if (alongLine) { glyphInstances = []; - labelMinScale = getLineGlyphs(glyphInstances, anchor, centerX, line, anchor.segment, false); + labelMinScale = Math.max(labelMinScale, getLineGlyphs(glyphInstances, anchor, centerX, line, anchor.segment, false)); if (keepUpright) { - labelMinScale = Math.min(labelMinScale, getLineGlyphs(glyphInstances, anchor, centerX, line, anchor.segment, true)); + labelMinScale = Math.max(labelMinScale, getLineGlyphs(glyphInstances, anchor, centerX, line, anchor.segment, true)); } } else { diff --git a/src/ui/map.js b/src/ui/map.js index 0a5cffb180b..8f1e091eb6e 100755 --- a/src/ui/map.js +++ b/src/ui/map.js @@ -848,6 +848,7 @@ class Map extends Camera { this.style._remove(); this.off('rotate', this.style._redoPlacement); this.off('pitch', this.style._redoPlacement); + this.off('move', this.style._redoPlacement); } if (!style) { @@ -863,6 +864,7 @@ class Map extends Camera { this.on('rotate', this.style._redoPlacement); this.on('pitch', this.style._redoPlacement); + this.on('move', this.style._redoPlacement); return this; } diff --git a/src/util/throttler.js b/src/util/throttler.js new file mode 100644 index 00000000000..ab5120adb0f --- /dev/null +++ b/src/util/throttler.js @@ -0,0 +1,55 @@ +'use strict'; + +const browser = require('./browser'); + +/** + * Throttles the provided function to run at most every + * 'frequency' milliseconds + * + * @interface Throttler + * @private + */ +class Throttler { + + constructor(frequency, throttledFunction) { + this.frequency = frequency; + this.throttledFunction = throttledFunction; + this.lastInvocation = 0; + } + + /** + * Request an invocation of the throttled function. + * + * @memberof Throttler + * @instance + */ + invoke() { + if (this.pendingInvocation) { + return; + } + + const timeToNextInvocation = this.lastInvocation === 0 ? + 0 : + (this.lastInvocation + this.frequency) - browser.now(); + + if (timeToNextInvocation <= 0) { + this.lastInvocation = browser.now(); + this.throttledFunction(); + } else { + this.pendingInvocation = setTimeout(() => { + this.pendingInvocation = undefined; + this.lastInvocation = browser.now(); + this.throttledFunction(); + }, timeToNextInvocation); + } + } + + stop() { + if (this.pendingInvocation) { + clearTimeout(this.pendingInvocation); + this.pendingInvocation = undefined; + } + } +} + +module.exports = Throttler; diff --git a/test/integration/query-tests/edge-cases/box-cutting-antimeridian-z0/expected.json b/test/integration/query-tests/edge-cases/box-cutting-antimeridian-z0/expected.json index 912955465e3..fcbe2f6360e 100644 --- a/test/integration/query-tests/edge-cases/box-cutting-antimeridian-z0/expected.json +++ b/test/integration/query-tests/edge-cases/box-cutting-antimeridian-z0/expected.json @@ -1,54 +1,54 @@ [ - { - "properties": { - "id": "B" + { + "properties": { + "id": "B" + }, + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 90, + 0 + ] + } }, - "type": "Feature", - "geometry": { - "type": "Point", - "coordinates": [ - -270, - 0 - ] - } - }, - { - "properties": { - "id": "C" - }, - "type": "Feature", - "geometry": { - "type": "Point", - "coordinates": [ - -90, - 0 - ] - } - }, - { - "properties": { - "id": "B" + { + "properties": { + "id": "C" + }, + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 270, + 0 + ] + } }, - "type": "Feature", - "geometry": { - "type": "Point", - "coordinates": [ - 90, - 0 - ] - } - }, - { - "properties": { - "id": "C" + { + "properties": { + "id": "B" + }, + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -270, + 0 + ] + } }, - "type": "Feature", - "geometry": { - "type": "Point", - "coordinates": [ - 270, - 0 - ] + { + "properties": { + "id": "C" + }, + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -90, + 0 + ] + } } - } -] \ No newline at end of file +] diff --git a/test/integration/query-tests/results.html.tmpl b/test/integration/query-tests/results.html.tmpl index 0ac1e923251..63c31a43677 100644 --- a/test/integration/query-tests/results.html.tmpl +++ b/test/integration/query-tests/results.html.tmpl @@ -11,7 +11,7 @@ -

<%- r.group %>/<%- r.test %>

+

<%- r.group %>/<%- r.test %> <% if (!r.ok) { %>(failed)<% } %>