前言
在这个行业内卷的时代,普通的2D可视化展示方式已经不能满足金主们的眼球,各大厂可视化平台3D酷炫效果在这几年也是陆续上线百花齐放,倒逼着食物链各层的公司也做出改变。我们的技术团队想要在其中有一席之地,自然也需要在这方面花费些功夫。
可视化效果归根结底还是对三维空间上点线面体的操作,今天我们用一个案例来讲一下以高德地图为底图的3D效果如何实现。阅读这篇文章需要了解THREE的一些基础知识,比如对renderer、scene、camera、mesh这些概念能理解。
实现思路
高德地图AMap JS API 2.0 已实现了对THREE等web 3d引擎的支持,即提供了GLSCustomLayer自定义图层可供开发者们自由发挥,在GIS地图的基础上叠加各种元素实现更有吸引力的可视化效果。
自定义图层-GLCustomLayer 结合 THREE-自有数据图层-示例中心-JS API 2.0 示例 | 高德地图API
事实上,底图AMap和GLCustomerLayer是两个相对独立的图层,两者靠每一帧渲染的时候同步透视相机的参数来维持“肉眼可视”的关联效果,看上去好像两个图层的坐标系是一致的。
高德官方提供的LOCA数据可视化模块展示了相对可观的3D效果,相信也是使用了类似的思路去实现并进行封装的,下面我们可以用一个案例进行分析。
深圳高峰期路口-贴地点-示例详情-Loca API 2.0 | 高德地图API
需求分析
我们用GLCustomeLayer实现与官方示例ScatterLayer图层贴地呼吸点相同的效果。在指定的坐标上放置波纹状的平面点,该图层还有如下需求:
- 支持动画
- 元素的外观和动画可配置
- 元素尺寸可配置
- 动画的播放速度可配置
- 元素支持鼠标事件监听
开发思路
话说这个图层支持使用marker或者Canvas也能实现,为啥要用GLCustomLayer? 因为marker的本质是dom元素,随着元素数量的上升到一定程度性能会急剧下降;Canvas是个吃cpu的东西,数据量一上去随时能让浏览器崩溃。而GLCustomLayer主要吃的是gpu,在保证大数据量性能之余,还能避免与主业务逻辑抢占计算资源的风险。
我们来分析一下需求:
-
支持动画
如果使用自定义图层实现,就需要有个定时更新的机制
-
元素的外观和动画可配置
实现这一需求有多种手段,元素外观可以编写代码实现,但这一方式不利于新手和非技术人员参与后期元素外观和动画的调整; 可以使用逐帧动画的方式实现,有点类似古老的css sprite技术,虽然效果天花板没有比代码好,但最大的优先就是外观和动画的替换工作可以直接甩手给更懂视觉设计的设计师去做。
-
元素尺寸可配置
创建元素时配置尺寸即可
-
动画的播放速度可配置
定时更新时增加跳帧判断,即从1秒换一帧改为2秒换一帧,那么视觉上看速度就慢了一半;提速的方法同理。
-
元素支持鼠标事件监听
3D场景里的物体拾取功能是使用Raycaster判断的,即在当前视窗鼠标位置射出一条看不见的射线,判断当前场景里所有物体有哪些被射线击中,被击中的即为被拾取物体。因此我们需要将每个元素塑造为单独的个体,以便拾取判断。
实现步骤
结合上面的需求,我们的设计思路是这样的。
-
根据获取到的坐标数据,在地图上以坐标为中心生成贴地的平面
-
使用图片创建材质Material,给平面赋予材质Material
-
定时调整材质,实现动画效果
代码实现
- 初始化高德底图map,只需要注意viewMode为3D
var map = new AMap.Map('container', {
center: [113.528674, 22.793483],
zooms: [2, 20],
zoom: 14,
viewMode: '3D',
pitch: 50,
});
- 获取原始数据,提前做坐标系转换,一定要提前做,否则高德的自定义坐标系没有办法初始化,会导致后面无法正常工作(坑)。
// 高德附带的自定义坐标系类
var customCoords = map.customCoords
// 数据使用转换工具进行转换,这个操作必须要在获取镜头参数 函数之前执行
// 否则将会获得一个错误信息。
var data = customCoords.lngLatsToCoords([
[116.52, 39.79],
[116.54, 39.79],
[116.56, 39.79],
]);
- 创建GLCustomLayer图层,初始化three.js实例相关的成员renderer、scene、camera,为后面图层元素提供容器
const layer = new AMap.GLCustomLayer({
zIndex: 9999,
init: (gl) => {
this.initThree(gl)
//在这里增加地图元素
this.initPoints()
//定时更新方法
this.animate()
},
render()=>{
//render()这块代码内容几乎是固定的
let {scene, renderer, camera, customCoords} = this
//同步相机
const { near, far, fov, up, lookAt, position } = customCoords.getCameraParams()
camera.near = near// 近平面
camera.far = far // 远平面
camera.fov = fov // 视野范围
camera.position.set(...position)
camera.up.set(...up)
camera.lookAt(...lookAt)
//更新相机坐标系
camera.updateProjectionMatrix()
//重新渲染画面
renderer.render(scene, camera)
},
}
map.add(layer)
//创建THREE初始内容
initThree (gl) {
//相机的初始参数不重要,因为在后面地图的每一次更新都需要同步相机
this.camera = new THREE.PerspectiveCamera(60, clientWidth / clientHeight, 100, 1 << 30)
//渲染器
const renderer = new THREE.WebGLRenderer({
context: gl
})
// 自动清空画布这里必须设置为 false,否则地图底图将无法显示
renderer.autoClear = false
renderer.outputEncoding = THREE.sRGBEncoding
this.renderer = renderer
//场景
this.scene = new THREE.Scene()
}
- 生成图层元素。这一步需要与后面的动画效果相结合,既然决定用帧动画的方式实现动画效果,就需要准备一张支持alpha透明的纹理图片,该图片包含了一个完整循环动画所需的所有帧。初始状态只取第一帧。
代码中需要注意几个点,才能实现纹理偏移的效果。
-
水平方向纹理重复方式必须平铺,有点类似css背景重复repeat-x
texture.wrapS = texture.wrapT = THREE.RepeatWrapping ;
-
设置xy方向重复次数,本案例中水平方向x有23帧,仅取第1帧,所以重复次数设为1/23;垂直方向y取1即可。
texture.repeat.set( 1/23, 1);
initPoints() {
const { scene } = this
//定义波纹材质
const loader = new THREE.TextureLoader()
let texture = loader.load(`./static/texture/texture_wave_circle.png`)
texture.wrapS = texture.wrapT = THREE.RepeatWrapping
//设置xy方向重复次数,x轴有frameX帧,仅取第一帧,y轴只有一帧,取1即可
texture.repeat.set( 1/this._frameX, 1);
this._texture = texture
this._data.forEach(([x, y]) => {
const geometry = this.generateGeometry(x, y)
let mt = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
depthTest: false
})
let mesh = new THREE.Mesh(geometry, mt);
mesh.position.set(x, y, 0)
scene.add(mesh)
})
generateGeometry(x,y){
//创建一个限定了宽高的平面几何体
let res = new THREE.PlaneGeometry(this.radius * 2, this.radius * 2)
return res
}
- 实现动画效果,使用浏览器自带的动画帧函数requestAnimationFrame 不断地改变元素属性并重新渲染。
// 定时更新方法,在GLCumstomLayer初始化完成时执行
animate(){
if (this._isAnimate) {
this.update()
}
//强迫地图重新渲染,否则地图只会在zoom和move时重新渲染
this.map.render()
requestAnimationFrame(()=>{
this.animate()
})
}
// 更新纹理偏移量
update() {
// 这是1个累计的偏移值,取值范围[0, N]
this._offset += this.speed
// 累计偏移值取整,纹理偏移到对应值,用于跳帧控制速度
this._texture.offset.x = Math.floor(this._offset) / 23
}
值得注意
-
THREE版本保持在0.117或以下,因0.118之后的版本对GL渲染方式做了大的调整,高德开发团队还来不及支持。如果强行使用高版本THREE会出现Mesh面丢失之类的奇奇怪怪的问题。
-
浏览器重绘回调函数的执行频率通常是60次/秒,一旦在这里做太多繁复的运算就会产生非常大的性能问题。因此我们应该注意几点:
- 避免使用for循环运算
- 减少浮点计算的使用,因浮点计算开支较大
- 使用stats进行性能检测
- 为了降低学习门槛,案例中元素的几何体使用了现成的平面几何体 PlaneGeometry,事实上有点浪费性能了,我们可以换成 BufferGeometry 自己创建平面,不过这块需要自己提前造好顶点和UV,需要有这方面的基础知识。