Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Globe - circle and heatmap layers #4015

Merged
merged 15 commits into from
Apr 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 69 additions & 29 deletions src/data/bucket/circle_bucket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,16 @@ import type Point from '@mapbox/point-geometry';
import type {FeatureStates} from '../../source/source_state';
import type {ImagePosition} from '../../render/image_atlas';
import type {VectorTileLayer} from '@mapbox/vector-tile';
import {CircleGranularity} from '../../render/subdivision_granularity_settings';

const VERTEX_MIN_VALUE = -32768; // -(2^15)

// Extrude is in range 0..7, which will be mapped to -1..1 in the shader.
function addCircleVertex(layoutVertexArray, x, y, extrudeX, extrudeY) {
// We pack circle position and extrude into range 0..65535, but vertices are stored as *signed* 16-bit integers, so we need to offset the number by 2^15.
layoutVertexArray.emplaceBack(
(x * 2) + ((extrudeX + 1) / 2),
(y * 2) + ((extrudeY + 1) / 2));
VERTEX_MIN_VALUE + (x * 8) + extrudeX,
VERTEX_MIN_VALUE + (y * 8) + extrudeY);
}

/**
Expand Down Expand Up @@ -82,12 +87,21 @@ export class CircleBucket<Layer extends CircleStyleLayer | HeatmapStyleLayer> im
let circleSortKey = null;
let sortFeaturesByKey = false;

// Heatmap circles are usually large (and map-pitch-aligned), tessellate them to allow curvature along the globe.
let subdivide = styleLayer.type === 'heatmap';

// Heatmap layers are handled in this bucket and have no evaluated properties, so we check our access
if (styleLayer.type === 'circle') {
circleSortKey = (styleLayer as CircleStyleLayer).layout.get('circle-sort-key');
const circleStyle = (styleLayer as CircleStyleLayer);
HarelM marked this conversation as resolved.
Show resolved Hide resolved
circleSortKey = circleStyle.layout.get('circle-sort-key');
sortFeaturesByKey = !circleSortKey.isConstant();

// Circles that are "printed" onto the map surface should be tessellated to follow the globe's curvature.
subdivide = subdivide || circleStyle.paint.get('circle-pitch-alignment') === 'map';
}

const granularity = subdivide ? options.subdivisionGranularity.circle : 1;

for (const {feature, id, index, sourceLayerIndex} of features) {
const needGeometry = this.layers[0]._featureFilter.needGeometry;
const evaluationFeature = toEvaluationFeature(feature, needGeometry);
Expand Down Expand Up @@ -121,7 +135,7 @@ export class CircleBucket<Layer extends CircleStyleLayer | HeatmapStyleLayer> im
const {geometry, index, sourceLayerIndex} = bucketFeature;
const feature = features[index].feature;

this.addFeature(bucketFeature, geometry, index, canonical);
this.addFeature(bucketFeature, geometry, index, canonical, granularity);
options.featureIndex.insert(feature, geometry, index, sourceLayerIndex, this.index);
}
}
Expand Down Expand Up @@ -156,37 +170,63 @@ export class CircleBucket<Layer extends CircleStyleLayer | HeatmapStyleLayer> im
this.segments.destroy();
}

addFeature(feature: BucketFeature, geometry: Array<Array<Point>>, index: number, canonical: CanonicalTileID) {
addFeature(feature: BucketFeature, geometry: Array<Array<Point>>, index: number, canonical: CanonicalTileID, granularity: CircleGranularity = 1) {
// Since we store the circle's center in each vertex, we only have 3 bits for actual vertex position in each axis.
// Thus the valid range of positions is 0..7.
// This gives us 4 possible granularity settings that are symmetrical.

// This array stores vertex positions that should by used by the tessellated quad.
let extrudes: Array<number>;

switch (granularity) {
case 1:
extrudes = [0, 7];
break;
case 3:
extrudes = [0, 2, 5, 7];
break;
case 5:
extrudes = [0, 1, 3, 4, 6, 7];
break;
case 7:
extrudes = [0, 1, 2, 3, 4, 5, 6, 7];
break;
default:
throw new Error(`Invalid circle bucket granularity: ${granularity}; valid values are 1, 3, 5, 7.`);
}

const verticesPerAxis = extrudes.length;

for (const ring of geometry) {
for (const point of ring) {
const x = point.x;
const y = point.y;
const vx = point.x;
const vy = point.y;

// Do not include points that are outside the tile boundaries.
if (x < 0 || x >= EXTENT || y < 0 || y >= EXTENT) continue;

// this geometry will be of the Point type, and we'll derive
// two triangles from it.
//
// ┌─────────┐
// │ 3 2 │
// │ │
// │ 0 1 │
// └─────────┘

const segment = this.segments.prepareSegment(4, this.layoutVertexArray, this.indexArray, feature.sortKey);
const index = segment.vertexLength;
if (vx < 0 || vx >= EXTENT || vy < 0 || vy >= EXTENT) {
continue;
}

addCircleVertex(this.layoutVertexArray, x, y, -1, -1);
addCircleVertex(this.layoutVertexArray, x, y, 1, -1);
addCircleVertex(this.layoutVertexArray, x, y, 1, 1);
addCircleVertex(this.layoutVertexArray, x, y, -1, 1);

this.indexArray.emplaceBack(index, index + 1, index + 2);
this.indexArray.emplaceBack(index, index + 3, index + 2);
const segment = this.segments.prepareSegment(verticesPerAxis * verticesPerAxis, this.layoutVertexArray, this.indexArray, feature.sortKey);
const index = segment.vertexLength;

segment.vertexLength += 4;
segment.primitiveLength += 2;
for (let y = 0; y < verticesPerAxis; y++) {
for (let x = 0; x < verticesPerAxis; x++) {
addCircleVertex(this.layoutVertexArray, vx, vy, extrudes[x], extrudes[y]);
}
}

for (let y = 0; y < verticesPerAxis - 1; y++) {
for (let x = 0; x < verticesPerAxis - 1; x++) {
const lowerIndex = index + y * verticesPerAxis + x;
const upperIndex = index + (y + 1) * verticesPerAxis + x;
this.indexArray.emplaceBack(lowerIndex, lowerIndex + 1, upperIndex + 1);
this.indexArray.emplaceBack(lowerIndex, upperIndex, upperIndex + 1);
}
}

segment.vertexLength += verticesPerAxis * verticesPerAxis;
segment.primitiveLength += (verticesPerAxis - 1) * (verticesPerAxis - 1) * 2;
}
}

Expand Down
18 changes: 9 additions & 9 deletions src/geo/projection/globe.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import {mat4} from 'gl-matrix';
import {GlobeProjection} from './globe';
import {EXTENT} from '../../data/extent';
import {Transform} from '../transform';
import {expectToBeCloseToArray} from './mercator.test';
import type {TransformLike} from './projection';
import {LngLat} from '../lng_lat';

describe('GlobeProjection', () => {
describe('getProjectionData', () => {
Expand Down Expand Up @@ -103,21 +104,20 @@ function createMockTransform(object: {
};
pitchDegrees?: number;
angleDegrees?: number;
}): Transform {
}): TransformLike {
const pitchDegrees = object.pitchDegrees ? object.pitchDegrees : 0;
return {
center: {
lat: object.center ? (object.center.latDegrees / 180.0 * Math.PI) : 0,
lng: object.center ? (object.center.lngDegrees / 180.0 * Math.PI) : 0,
},
center: new LngLat(
object.center ? (object.center.lngDegrees / 180.0 * Math.PI) : 0,
object.center ? (object.center.latDegrees / 180.0 * Math.PI) : 0),
worldSize: 10.5 * 512,
_fov: Math.PI / 4.0,
fov: 45.0,
width: 640,
height: 480,
cameraToCenterDistance: 759,
_pitch: pitchDegrees / 180.0 * Math.PI, // in radians
pitch: pitchDegrees, // in degrees
angle: object.angleDegrees ? (object.angleDegrees / 180.0 * Math.PI) : 0,
zoom: 0,
} as Transform;
invProjMatrix: null,
};
}
58 changes: 36 additions & 22 deletions src/geo/projection/globe.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
import {mat4, vec3, vec4} from 'gl-matrix';
import {Context} from '../../gl/context';
import {CanonicalTileID, UnwrappedTileID} from '../../source/tile_id';
import type {Context} from '../../gl/context';
import type {CanonicalTileID, UnwrappedTileID} from '../../source/tile_id';
import {PosArray, TriangleIndexArray} from '../../data/array_types.g';
import {Mesh} from '../../render/mesh';
import {EXTENT} from '../../data/extent';
import {SegmentVector} from '../../data/segment';
import posAttributes from '../../data/pos_attributes';
import {Transform} from '../transform';
import {Tile} from '../../source/tile';
import type {Tile} from '../../source/tile';
import {browser} from '../../util/browser';
import {easeCubicInOut, lerp} from '../../util/util';
import {mercatorYfromLat} from '../mercator_coordinate';
import {NORTH_POLE_Y, SOUTH_POLE_Y} from '../../render/subdivision';
import {SubdivisionGranularityExpression, SubdivisionGranularitySetting} from '../../render/subdivision_granularity_settings';
import Point from '@mapbox/point-geometry';
import {ProjectionData} from '../../render/program/projection_program';
import {Projection, ProjectionGPUContext} from './projection';
import type {ProjectionData} from '../../render/program/projection_program';
import type {Projection, ProjectionGPUContext, TransformLike} from './projection';
import {PreparedShader, shaders} from '../../shaders/shaders';
import {MercatorProjection, translatePosition} from './mercator';
import {ProjectionErrorMeasurement} from './globe_projection_error_measurement';
import type {LngLat} from '../lng_lat';

/**
* The size of border region for stencil masks, in internal tile coordinates.
Expand All @@ -39,13 +39,14 @@ const granularitySettingsGlobe: SubdivisionGranularitySetting = new SubdivisionG
// This si not needed on fill, because fill geometry tends to already be
// highly tessellated and granular at high zooms.
tile: new SubdivisionGranularityExpression(128, 16),
circle: 3
});

export class GlobeProjection implements Projection {
private _mercator: MercatorProjection;

private _tileMeshCache: {[_: string]: Mesh} = {};
private _cachedClippingPlane: [number, number, number, number] = [1, 0, 0, 0];
private _cachedClippingPlane: vec4 = [1, 0, 0, 0];

// Transition handling
private _lastGlobeStateEnabled: boolean = true;
Expand Down Expand Up @@ -185,9 +186,9 @@ export class GlobeProjection implements Projection {
this._errorCorrectionUsable = lerp(this._errorCorrectionPreviousValue, newCorrection, easeCubicInOut(mix));
}

public updateProjection(transform: Transform): void {
public updateProjection(transform: TransformLike): void {
this._errorQueryLatitudeDegrees = transform.center.lat;
this._updateAnimation(transform);
this._updateAnimation(transform.zoom);

// We want zoom levels to be consistent between globe and flat views.
// This means that the pixel size of features at the map center point
Expand All @@ -197,9 +198,9 @@ export class GlobeProjection implements Projection {
// Construct a completely separate matrix for globe view
const globeMatrix = new Float64Array(16) as any;
const globeMatrixUncorrected = new Float64Array(16) as any;
mat4.perspective(globeMatrix, transform._fov, transform.width / transform.height, 0.5, transform.cameraToCenterDistance + globeRadiusPixels * 2.0); // just set the far plane far enough - we will calculate our own z in the vertex shader anyway
mat4.perspective(globeMatrix, transform.fov * Math.PI / 180, transform.width / transform.height, 0.5, transform.cameraToCenterDistance + globeRadiusPixels * 2.0); // just set the far plane far enough - we will calculate our own z in the vertex shader anyway
mat4.translate(globeMatrix, globeMatrix, [0, 0, -transform.cameraToCenterDistance]);
mat4.rotateX(globeMatrix, globeMatrix, -transform._pitch);
mat4.rotateX(globeMatrix, globeMatrix, -transform.pitch * Math.PI / 180);
mat4.rotateZ(globeMatrix, globeMatrix, -transform.angle);
mat4.translate(globeMatrix, globeMatrix, [0.0, 0, -globeRadiusPixels]);
// Rotate the sphere to center it on viewed coordinates
Expand Down Expand Up @@ -237,7 +238,7 @@ export class GlobeProjection implements Projection {
data['u_projection_matrix'] = useAtanCorrection ? this._globeProjMatrix : this._globeProjMatrixNoCorrection;
}

data['u_projection_clipping_plane'] = [...this._cachedClippingPlane];
data['u_projection_clipping_plane'] = this._cachedClippingPlane as [number, number, number, number];
data['u_projection_transition'] = this._globeness;

return data;
Expand All @@ -255,7 +256,10 @@ export class GlobeProjection implements Projection {
return dirty;
}

private _computeClippingPlane(transform: Transform, globeRadiusPixels: number): [number, number, number, number] {
private _computeClippingPlane(
transform: { center: LngLat; pitch: number; angle: number; cameraToCenterDistance: number },
globeRadiusPixels: number
): vec4 {
// We want to compute a plane equation that, when applied to the unit sphere generated
// in the vertex shader, places all visible parts of the sphere into the positive half-space
// and all the non-visible parts in the negative half-space.
Expand Down Expand Up @@ -322,15 +326,21 @@ export class GlobeProjection implements Projection {
return [...planeVector, -tangentPlaneDistanceToC * scale];
}

/**
* Given a 2D point in the mercator base tile, returns its 3D coordinates on the surface of a unit sphere.
*/
private _projectToSphere(mercatorX: number, mercatorY: number): vec3 {
const sphericalX = mercatorX * Math.PI * 2.0 + Math.PI;
const sphericalY = 2.0 * Math.atan(Math.exp(Math.PI - (mercatorY * Math.PI * 2.0))) - Math.PI * 0.5;
return this._angularCoordinatesToVector(sphericalX, sphericalY);
}

const len = Math.cos(sphericalY);
private _angularCoordinatesToVector(lngRadians: number, latRadians: number): vec3 {
const len = Math.cos(latRadians);
return [
Math.sin(sphericalX) * len,
Math.sin(sphericalY),
Math.cos(sphericalX) * len
Math.sin(lngRadians) * len,
Math.sin(latRadians),
Math.cos(lngRadians) * len
];
}

Expand Down Expand Up @@ -369,7 +379,7 @@ export class GlobeProjection implements Projection {
};
}

public transformLightDirection(transform: Transform, dir: vec3): vec3 {
public transformLightDirection(transform: { center: LngLat }, dir: vec3): vec3 {
const sphereX = transform.center.lng * Math.PI / 180.0;
const sphereY = transform.center.lat * Math.PI / 180.0;

Expand Down Expand Up @@ -397,7 +407,7 @@ export class GlobeProjection implements Projection {
return normalized;
}

public getPixelScale(transform: Transform): number {
public getPixelScale(transform: { center: LngLat }): number {
const globePixelScale = 1.0 / Math.cos(transform.center.lat * Math.PI / 180);
const flatPixelScale = 1.0;
if (this.useGlobeRendering) {
Expand All @@ -406,7 +416,11 @@ export class GlobeProjection implements Projection {
return flatPixelScale;
}

private _updateAnimation(transform: Transform) {
public getCircleRadiusCorrection(transform: { center: LngLat }): number {
return Math.cos(transform.center.lat * Math.PI / 180);
}

private _updateAnimation(currentZoom: number) {
// Update globe transition animation
const globeState = this._globeProjectionOverride;
const currentTime = browser.now();
Expand All @@ -425,7 +439,7 @@ export class GlobeProjection implements Projection {
}

// Update globe zoom transition
const currentZoomState = transform.zoom >= maxGlobeZoom;
const currentZoomState = currentZoom >= maxGlobeZoom;
if (currentZoomState !== this._lastLargeZoomState) {
this._lastLargeZoomState = currentZoomState;
this._lastLargeZoomStateChange = currentTime;
Expand Down Expand Up @@ -460,7 +474,7 @@ export class GlobeProjection implements Projection {
return mesh;
}

public translatePosition(transform: Transform, tile: Tile, translate: [number, number], translateAnchor: 'map' | 'viewport'): [number, number] {
public translatePosition(transform: { angle: number; zoom: number }, tile: Tile, translate: [number, number], translateAnchor: 'map' | 'viewport'): [number, number] {
// In the future, some better translation for globe and other weird projections should be implemented here,
// especially for the translateAnchor==='viewport' case.
return translatePosition(transform, tile, translate, translateAnchor);
Expand Down
Loading