mapbox-gl | 4.1 threebox源码解析
简述
threebox 是一个在 mapbox 中使用 three.js 的工具库,它在 mapbox 1.x 版本中为 mapbox 提供了更好的三维展现能力。通过 threebox,开发者可以在 mapbox 地图上添加three场景,从而实现更加丰富的三维效果。
然而,当 mapbox 更新出球形地球功能时,threebox 并没有完全匹配这一更新,导致其在某些场景下的实用性下降。不过,在小场景下,这个工具仍然能够满足使用需求。
简单讲一下版本问题,threebox 的原作者在 mapbox 1.x 版本的基础上实现了核心功能,但后续并未继续维护。
幸运的是,jscastro76 fork 了该仓库,并继续在2.x(球形版本之前)进行维护和更新。因此,threebox 仍然是一个值得学习和使用的工具库,特别是对于那些需要在 mapbox 中实现三维效果的项目。
在本文中,将解析 threebox 的源码,了解其实现原理,包括坐标转换和矩阵转换等关键技术,同时会讲一些比较基础的东西。通过这些解析,希望能够帮助读者更好地理解 threebox 的工作机制,并在实际项目中灵活运用这一工具库。当然,由于自己学艺不精,讲述错误的还请评论更正。
目的
抽离出threebox核心,剔除不必要内容
原理
threebox 核心功能是两个:相机同步和坐标转换。相机同步包括对 three.js 相机的矩阵转换和对一个 THREE.Group 的矩阵转换。通过相机同步,能够确保 three.js 场景中的相机视角与 mapbox 地图的视角保持一致。
库结构(src)
// src
.
├─ animation // 模型动画相关
├─ camera
│ ├─ CameraSync // 相机异步(同步)
├─ objects // 将three的一些Geometry和loader等功能集成封装
├─ utils // 常量、通用函数等
├─ three.js // three源码
├─ Threebox.js //入口,库的主体由于本次仅讲解相机同步和坐标转换,其他的不涉及直接略过了。
测试示例
在直接进入核心前,需要有一个示例对效果进行调试,首先在mapbox中添加threebox场景,场景中添加了一个蓝色盒子
import mapboxgl from 'mapbox-gl'
import * as THREE from 'three'
import { Threebox } from 'threebox-plugin'
const map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/streets-v12',
center: [110.5, 40],
zoom: 14,
pitch: 40,
// projection: 'mercator',
})
const tb = (window.tb = new Threebox(
map,
map.getCanvas().getContext('webgl'),
{
defaultLights: true,
},
))
map.on('style.load', () => {
map.addLayer({
id: 'custom-threebox-model',
type: 'custom',
renderingMode: '3d',
onAdd() {
const geometry = new THREE.BoxGeometry(1000, 1000, 1000)
let cube = new THREE.Mesh(geometry, new THREE.MeshPhongMaterial({ color: 'blue' }))
cube = tb.Object3D({ obj: cube, units: 'meters' })
cube.setCoords([110.5, 40, 0])
tb.add(cube)
},
render() {
tb.update()
},
})
})然后在自定义一个ThreeLayer图层(mapbox customLayer)
参考官方案例,将其中内容去除。
export default class ThreeLayer {
constructor() {
}
onAdd(map: mapboxgl.Map, gl: WebGLRenderingContext) {
render(gl: WebGLRenderingContext, matrix: number[]) {
}
}完善自定义图层
初始化场景
查看threebox源码,入口文件Threebox.js 初始化时执行了init函数,这个函数主要是初始化了three的场景,然后绑定了一系列事件,事件暂且不管,先把初始化的一些内容处理一下。
// Threebox.js
// line 45 ~ 80
this.options = utils._validate(options || {}, defaultOptions);
this.map = map;
this.map.tb = this; //[jscastro] needed if we want to queryRenderedFeatures from map.onload
this.objects = new Objects();
this.mapboxVersion = parseFloat(this.map.version);
// Set up a THREE.js scene
this.renderer = new THREE.WebGLRenderer({
alpha: true,
antialias: true,
preserveDrawingBuffer: options.preserveDrawingBuffer,
canvas: map.getCanvas(),
context: glContext
});
this.renderer.setPixelRatio(window.devicePixelRatio);
this.renderer.setSize(this.map.getCanvas().clientWidth, this.map.getCanvas().clientHeight);
this.renderer.outputEncoding = THREE.sRGBEncoding;
this.renderer.autoClear = false;
// [jscastro] set labelRendered
this.labelRenderer = new LabelRenderer(this.map);
this.scene = new THREE.Scene();
this.world = new THREE.Group();
this.world.name = "world";
this.scene.add(this.world);
this.objectsCache = new Map();
this.zoomLayers = [];
this.fov = this.options.fov;上面的代码主要是初始化Three场景,将Scene、Renderer、Camera等作为属性写进类中,需要注意的是,它添加了一个Group到Scene中,并命名为world,后期添加任何Object都会添加到这里面,整理一下,在我们的代码中写入:
interface LayerOption {
map: mapboxgl.Map
renderer?: THREE.WebGLRenderer
helper?: boolean
}
const defaultLayerOption: any = {
map: null,
renderer: null,
}
export default class SThreeLayer {
option: LayerOption
renderer?: THREE.WebGLRenderer
scene: THREE.Scene
world: THREE.Group
map: mapboxgl.Map
camera: THREE.PerspectiveCamera
id: string
type: string
renderingMode: string
cameraSync?: CameraSync
constructor(options: LayerOption) {
this.id = 'threeLayer'
this.type = 'custom'
this.renderingMode = '3d'
this.option = Object.assign(defaultLayerOption, options)
this.map = this.option.map
this.scene = new THREE.Scene()
this.world = new THREE.Group()
this.scene.add(this.world)
this.camera = new THREE.PerspectiveCamera(
30,
this.map.getCanvas().offsetWidth / this.map.getCanvas().offsetHeight,
1,
10000,
)
}
onAdd(map: mapboxgl.Map, gl: WebGLRenderingContext) {
console.log(map, gl)
console.log(map.transform)
if (!this.option.renderer) {
this.renderer = new THREE.WebGLRenderer({
canvas: this.option.map.getCanvas(),
context: gl,
antialias: true,
})
}
else {
this.renderer = this.option.renderer
}
this.renderer.setPixelRatio(window.devicePixelRatio)
this.renderer.setSize(
this.map.getCanvas().offsetWidth,
this.map.getCanvas().offsetHeight,
)
this.renderer.autoClear = false
if (this.option.helper) {
const axesHelper = new THREE.AxesHelper(1000)
axesHelper.position.set(0, 0, 0)
this.scene.add(axesHelper)
}
// 光
const directionalLight = new THREE.DirectionalLight(0xFFFFFF)
directionalLight.position.set(0, -70, 100).normalize()
this.scene.add(directionalLight)
const directionalLight2 = new THREE.DirectionalLight(0xFFFFFF)
directionalLight2.position.set(0, 70, 100).normalize()
this.scene.add(new THREE.AmbientLight(0x666666))
this.scene.add(directionalLight2)
}
}更新场景
// Threebox.js
// line 899 ~ 916
update: function () {
if (this.map.repaint) this.map.repaint = false
var timestamp = Date.now();
// Update any animations
this.objects.animationManager.update(timestamp);
this.updateLightHelper();
// Render the scene and repaint the map
this.renderer.resetState(); //update threejs r126
this.renderer.render(this.scene, this.camera);
// [jscastro] Render any label
this.labelRenderer.render(this.scene, this.camera);
if (this.options.passiveRendering === false) this.map.triggerRepaint();
},初始化以后,再看看每次渲染更新的代码,并没有太特殊的操作,写入我们的代码中:
export default class SThreeLayer {
render(gl: WebGLRenderingContext, matrix: number[]) {
if (this.renderer) {
this.renderer.resetState()
this.renderer.render(this.scene, this.camera)
}
this.map.triggerRepaint()
}
}矩阵转换
常量
矩阵变换中,用到了很多常量,这部分代码在utils/constants.js中。
写人自己的代码中:
const WORLD_SIZE = 1024000 // TILE_SIZE * 2000
const FOV_ORTHO = 0.1 / 180 * Math.PI // Mapbox doesn't accept 0 as FOV
const FOV = Math.atan(3 / 4) // from Mapbox https://github.com/mapbox/mapbox-gl-js/blob/main/src/geo/transform.js#L93
// 平均半径
const EARTH_RADIUS = 6371008.8 // from Mapbox https://github.com/mapbox/mapbox-gl-js/blob/0063cbd10a97218fb6a0f64c99bf18609b918f4c/src/geo/lng_lat.js#L11
// 地球赤道周长
const EARTH_CIRCUMFERENCE_EQUATOR = 40075017 // from Mapbox https://github.com/mapbox/mapbox-gl-js/blob/0063cbd10a97218fb6a0f64c99bf18609b918f4c/src/geo/lng_lat.js#L117
export default {
WORLD_SIZE,
PROJECTION_WORLD_SIZE: WORLD_SIZE / (EARTH_RADIUS * Math.PI * 2),
MERCATOR_A: EARTH_RADIUS,
DEG2RAD: Math.PI / 180,
RAD2DEG: 180 / Math.PI,
EARTH_RADIUS,
EARTH_CIRCUMFERENCE: 2 * Math.PI * EARTH_RADIUS, // 40075000, // In meters
EARTH_CIRCUMFERENCE_EQUATOR,
FOV_ORTHO, // closest to 0
FOV, // Math.atan(3/4) radians. If this value is changed, FOV_DEGREES must be calculated
FOV_DEGREES: FOV * 180 / Math.PI, // Math.atan(3/4) in degrees
TILE_SIZE: 512,
}初始化CameraSync
初始化场景后,现在要考虑的是Three和mapbox的相机同步了,这部分代码在 camera/CameraSync.js中。
// CameraSync.js
// line 9 ~ 51
function CameraSync(map, camera, world) {
// console.log("CameraSync constructor");
this.map = map;
this.camera = camera;
this.active = true;
this.camera.matrixAutoUpdate = false; // We're in charge of the camera now!
// Postion and configure the world group so we can scale it appropriately when the camera zooms
this.world = world || new THREE.Group();
this.world.position.x = this.world.position.y = ThreeboxConstants.WORLD_SIZE / 2
this.world.matrixAutoUpdate = false;
// set up basic camera state
this.state = {
translateCenter: new THREE.Matrix4().makeTranslation(ThreeboxConstants.WORLD_SIZE / 2, -ThreeboxConstants.WORLD_SIZE / 2, 0),
worldSizeRatio: ThreeboxConstants.TILE_SIZE / ThreeboxConstants.WORLD_SIZE,
worldSize: ThreeboxConstants.TILE_SIZE * this.map.transform.scale
};
// Listen for move events from the map and update the Three.js camera
let _this = this; // keep the function on _this
this.map
.on('move', function () {
_this.updateCamera();
})
.on('resize', function () {
_this.setupCamera();
})
this.setupCamera();
}
CameraSync.prototype = {
setupCamera: function () {
const t = this.map.transform;
this.camera.aspect = t.width / t.height; //bug fixed, if aspect is not reset raycast will fail on map resize
this.halfFov = t._fov / 2;
this.cameraToCenterDistance = 0.5 / Math.tan(this.halfFov) * t.height;
const maxPitch = t._maxPitch * Math.PI / 180;
this.acuteAngle = Math.PI / 2 - maxPitch;
this.updateCamera();
},
}首先是初始化一些变量,这里我会做一些删减,仅留后面会用到的
export default class CameraSync {
map: mapboxgl.Map
camera: THREE.PerspectiveCamera
world: THREE.Group
state: {
translateCenter: THREE.Matrix4
worldSizeRatio: number
worldSize: number
}
halfFov?: number
cameraToCenterDistance?: number
cameraTranslateZ?: THREE.Matrix4
constructor(map: mapboxgl.Map, camera: THREE.PerspectiveCamera, world: THREE.Group) {
this.map = map
this.camera = camera
this.world = world
// 手动设置相机矩阵
this.camera.matrixAutoUpdate = false
this.world.position.x = this.world.position.y = Constant.WORLD_SIZE / 2
this.world.matrixAutoUpdate = false
// set up basic camera state
this.state = {
translateCenter: new THREE.Matrix4().makeTranslation(Constant.WORLD_SIZE / 2, -Constant.WORLD_SIZE / 2, 0),
worldSizeRatio: Constant.TILE_SIZE / Constant.WORLD_SIZE,
worldSize: Constant.TILE_SIZE * this.map.transform.scale,
}
}
setupCamera() {
const t = this.map.transform
this.camera.aspect = t.width / t.height
this.halfFov = t._fov / 2
this.cameraToCenterDistance = 0.5 / Math.tan(this.halfFov) * t.height
}
}CameraSync 属性表
| 属性名 | 类型 | 描述 |
|---|---|---|
map | mapboxgl.Map | Mapbox 地图实例 |
camera | THREE.Camera | Three.js 相机实例 |
world | THREE.Group | Three.js 场景中的一个组,后续以此为父 |
state | Object | 存储状态信息。 |
halfFov | number | 半焦 |
cameraToCenterDistance | number | 相机距离中心距离 |
cameraTranslateZ | THREE.Matrix4 | 相机Z轴平移值 |
手动设置相机矩阵
// 先把Group放到中间
this.world.position.x = this.world.position.y = Constant.WORLD_SIZE / 2
// 关闭矩阵更新,手动设置
this.world.matrixAutoUpdate = false更新相机矩阵
这一步会比较详细的讲解,所以逐行贴源码
// CameraSync.js
// line 54 ~ 146// 获取mapbox地图实例转换相关
const t = this.map.transform
// 设置相机宽高比
this.camera.aspect = t.width / t.height
// 基本是固定的,000
const offset = t.centerOffsetcenterOffset只有在map设置setPadding时才会产生影响,指在屏幕上设置进行偏移,一般在移动端导航时会用到,其他情况下都是0,0,0
// 视角与地面的夹角(弧度),大夹角 > 90度
const groundAngle = Math.PI / 2 + t._pitch
// 视角与地面的夹角的余弦值 小夹角 < 90度
const pitchAngle = Math.cos((Math.PI / 2) - t._pitch)mapbox最大倾角是85度
// 半焦
this.halfFov = t._fov / 2
// 相机到视图中心的距离
this.cameraToCenterDistance = 0.5 / Math.tan(this.halfFov) * t.heightt.height是canvas的高度,0.5 * t.height * tan(半焦) 获取到视图中心的距离
// 瓦片尺寸 * 缩放比例
const worldSize = t.tileSize * t.scale获取当前世界尺寸
// 根据纬度计算像素与米的比例
const pixelsPerMeter = this.mercatorZfromAltitude(1, t.center.lat) * worldSize
mercatorZfromAltitude(altitude: number, lat: number) {
return altitude / this.circumferenceAtLatitude(lat)
}
circumferenceAtLatitude(latitude: number) {
return Constant.EARTH_CIRCUMFERENCE * Math.cos(latitude * Math.PI / 180)
}上面这段代码和mapbox的计算方式是相同的,Constant.EARTH_CIRCUMFERENCE是使用地球平均半径计算的地球周长,Math.cos(latitude * Math.PI / 180) 先将纬度转弧度然后获取余弦值,最后乘以周长,获取到当前纬度的地球周长。
随后用1/当前纬度地球周长算出当前像素与米的比例
const fovAboveCenter = t._fov * (0.5 + t.centerOffset.y / t.height)视图中心以上的视角,加了一半的偏移,一般等于半焦
const minElevationInPixels = t.elevation ? t.elevation.getMinElevationBelowMSL() * pixelsPerMeter : 0与mapbox farthestPixelDistanceOnPlane 计算方式相同,先获取可见区域最低点的海拔,如果不加载地形则为0
const cameraToSeaLevelDistance = ((t._camera.position[2] * worldSize) - minElevationInPixels) / Math.cos(t._pitch)
const topHalfSurfaceDistance = Math.sin(fovAboveCenter) * cameraToSeaLevelDistance / Math.sin(this.clamp(Math.PI - groundAngle - fovAboveCenter, 0.01, Math.PI - 0.01))
const furthestDistance = pitchAngle * topHalfSurfaceDistance + cameraToSeaLevelDistance相机Z值 * 世界尺寸 - 最低海拔 再除以倾角余弦值,获取的是相机沿着视线方向到最低海拔的距离,并非垂直于地面的距离
随后根据此值计算出视锥体上半部分的表面距离,随后乘倾角余弦值,得到最终的距离
const horizonDistance = cameraToSeaLevelDistance * (1 / t._horizonShift)
// 取最小值
const farZ = Math.min(furthestDistance * 1.01, horizonDistance)horizonShift 该值调整地平线的垂直位置,mapbox 默认为0.1,且不改变。值越大,地平线越靠屏幕下侧。为1是,地平线在屏幕中间。
结合起来这是为了限制相机的远裁剪面在一个范围内
// 平移
this.cameraTranslateZ = new THREE.Matrix4().makeTranslation(0, 0, this.cameraToCenterDistance)
const nz = (t.height / 50)
const nearZ = Math.max(nz * pitchAngle, nz)
const h = t.height
const w = t.width
this.camera.projectionMatrix = this.makePerspectiveMatrix(t._fov, w / h, nearZ, farZ)
this.camera.projectionMatrix.elements[8] = -offset.x * 2 / t.width
this.camera.projectionMatrix.elements[9] = offset.y * 2 / t.height
makePerspectiveMatrix(fov: number, aspect: number, near: number, far: number) {
// 与mapbox-matrix库中的perspective方法一致
const out = new THREE.Matrix4()
const f = 1.0 / Math.tan(fov / 2)
const nf = 1 / (near - far)
// x, 0, a, 0,
// 0, y, b, 0,
// 0, 0, c, d,
// 0, 0, -1, 0
const newMatrix = [
f / aspect,
0,
0,
0,
0,
f,
0,
0,
0,
0,
(far + near) * nf,
-1,
0,
0,
(2 * far * near) * nf,
0,
]
out.elements = newMatrix
return out
}先创建一个平移矩阵,nz这个值,是为了修复mapbox中使用deck.gl产生的深度问题,Math.max(nz * pitchAngle, nz) 这句话在我看来是不需要的,不知道threebox为什么会加这句话。
makePerspectiveMatrix与mapbox-matrix库中的perspective方法一致,设置相机投影矩阵
随后将投影矩阵的第 8 个元素设置为 -offset.x * 2 / t.width,用于调整视图在 X 轴上的偏移量。 通过将 offset.x 乘以 2 / t.width,将偏移量从像素转换为归一化设备坐标(NDC)。 确保相机的投影矩阵正确考虑视图中心的偏移量,从而实现正确的视图渲染和用户交互。
const cameraWorldMatrix = this.calcCameraMatrix(t._pitch, t.angle)
if (t.elevation)
cameraWorldMatrix.elements[14] = t._camera.position[2] * t.worldSize
// 世界矩阵
this.camera.matrixWorld.copy(cameraWorldMatrix)
calcCameraMatrix(pitch: number, angle: number, trz?: THREE.Matrix4) {
const t = this.map.transform
const _pitch = (pitch === undefined) ? t._pitch : pitch
const _angle = (angle === undefined) ? t.angle : angle
const _trz = (trz === undefined) ? this.cameraTranslateZ : trz
return new THREE.Matrix4()
.premultiply(_trz)
.premultiply(new THREE.Matrix4().makeRotationX(_pitch))
.premultiply(new THREE.Matrix4().makeRotationZ(_angle))
}为了让射线(点击)有效,需要将相机的平移和旋转放到世界矩阵中
更新世界矩阵
// 世界矩阵
this.camera.matrixWorld.copy(cameraWorldMatrix)
const zoomPow = t.scale * this.state.worldSizeRatio
// Handle scaling and translation of objects in the map in the world's matrix transform, not the camera
const scale = new THREE.Matrix4()
const translateMap = new THREE.Matrix4()
const rotateMap = new THREE.Matrix4()
scale.makeScale(zoomPow, zoomPow, zoomPow)
const x = t.point.x
const y = t.point.y
translateMap.makeTranslation(-x, y, 0)
rotateMap.makeRotationZ(Math.PI)
this.world.matrix = new THREE.Matrix4()
.premultiply(rotateMap)
.premultiply(this.state.translateCenter)
.premultiply(scale)
.premultiply(translateMap)world设置世界矩阵,通过组合多个变换,依次应用了缩放、平移和旋转。
经过上述操作,three和mapbox的相机实现了基本的同步,接下来是对坐标的转换
坐标转换
// utils/utils.js
// line 98 ~ 118
projectToWorld(coords) {
// 计算墨卡托投影
const projected = [
-Constant.MERCATOR_A * Constant.DEG2RAD * coords[0] * Constant.PROJECTION_WORLD_SIZE,
-Constant.MERCATOR_A * Math.log(Math.tan((Math.PI * 0.25) + (0.5 * Constant.DEG2RAD * coords[1]))) * Constant.PROJECTION_WORLD_SIZE,
]
if (!coords[2]) {
projected.push(0)
}
else {
const pixelsPerMeter = this.projectedUnitsPerMeter(coords[1])
projected.push(coords[2] * pixelsPerMeter)
}
const result = new THREE.Vector3(projected[0], projected[1], projected[2])
return result
}其实转换的代码并不复杂,不过需要理解为什么这么算
// 墨卡托投影公式
x = R * λ
y = R * ln(tan(π/4 + φ/2))上述为计算公式,说先看一下mapbox如何计算的:
x = (180 + lng) / 360
y = (180 - (180 / Math.PI * Math.log(Math.tan(Math.PI / 4 + lat * Math.PI / 360)))) / 360threebox计算为:
x = -Constant.MERCATOR_A * Constant.DEG2RAD * coords[0] * Constant.PROJECTION_WORLD_SIZE
y = -Constant.MERCATOR_A * Math.log(Math.tan((Math.PI * 0.25) + (0.5 * Constant.DEG2RAD * coords[1]))) * Constant.PROJECTION_WORLD_SIZE可以看出,计算原理是一样的,只是计算的结果的范围不同,对于Z值通过像素转米计算出来,最终得到转化后的坐标
替换为mapbox属性
threebox为了兼容1.x 2.x代码,很多属性都是自己在计算和管理,通过对比mapbox的实现方式,实际上很多都是从mapbox照搬过来的,并且这些属性都已经计算过了,不需要重复计算,现在我们不考虑兼容问题,可以将大部分属性和函数直接替换成mapbox的
以下为替换后的代码
updateCamera() {
const t = this.map.transform
this.camera.aspect = t.aspect
this.halfFov = t._fov / 2
// 平移
this.cameraTranslateZ = new THREE.Matrix4().makeTranslation(0, 0, t.cameraToCenterDistance)
// 与mapbox - matrix库中的perspective方法一致
// 投影矩阵
this.camera.projectionMatrix = this.makePerspectiveMatrix(t._fov, t.aspect, t._nearZ, t._farZ)
this.camera.projectionMatrix.elements[8] = -t.centerOffset.x * 2 / t.width
this.camera.projectionMatrix.elements[9] = t.centerOffset.y * 2 / t.height
const cameraWorldMatrix = this.calcCameraMatrix(t._pitch, t.angle)
if (t.elevation)
cameraWorldMatrix.elements[14] = t._camera.position[2] * t.worldSize
this.camera.matrixWorld.copy(cameraWorldMatrix)
const zoomPow = t.scale * this.state.worldSizeRatio
const scale = new THREE.Matrix4()
const translateMap = new THREE.Matrix4()
const rotateMap = new THREE.Matrix4()
scale.makeScale(zoomPow, zoomPow, zoomPow)
const x = t.point.x
const y = t.point.y
translateMap.makeTranslation(-x, y, 0)
rotateMap.makeRotationZ(Math.PI)
this.world.matrix = new THREE.Matrix4()
.premultiply(rotateMap)
.premultiply(this.state.translateCenter)
.premultiply(scale)
.premultiply(translateMap)
}总结
整体流程大概是这样,还有一些细节问题还需要思考,后续仍会反复整理这些代码,直至完全理解
