本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
这次我想换写作方式和大家分享1个解决问题的思路,讲述自己是如何通过排查原因、解决问题、迭代优化,一步步实现某个具体需求的。
发现问题
前几天刚对一个项目的可视化大屏做了视觉上的优化,添加了一些基于GIS数据而构建的webGL图层(以下简称gl-layers),以为高枕无忧了,然而上了测试环境后同事截图发来了这样问题: 当地图调整到某个角度后,基于路灯的设备点标记图层 iconLayer居然完全前置在其他图层可视化图层上面,并造成了视觉上的遮挡。这该如何是好?
排查原因
造成这个问题的原因,简单来说就是当前的iconLayer和其他可视化图层gl-layers不在同一个“空间维度”,虽然两者都是通过高德地图JSAPI提供的CanvasLayer去实现的,但iconLayer的实现方案是通过地理坐标转屏幕像素坐标的方式,让数据对应的图标在二维空间呈现;而gl-layers是使用three.js渲染、加上与地图AMap空间对齐的方式实现,是基于三维空间构建的图层。因此iconLayer和gl-layres之间不构成深度测试关系(在three.js中称为depthTest),因此无法做出如下图的空间远近遮挡效果。
depthTest深度测试是一种在3D渲染中用来确定哪些物体应该被绘制的技术。当启用深度测试时,对于每个要绘制的像素,Three.js会检查它在场景中的深度值是否小于当前存储在深度缓冲区中的值。如果小于,则该像素被认为是可见的,会被绘制;否则该像素会被丢弃。
面对这个问题,目前最直接的解决方案是在gl-layers实现iconLayer,把图层都放到同个空间维度,就能解决图层遮挡问题,顺便解决其他问题。时间有限,我们可能不会一次性就把问题处理得完美,但可以先从图层的最小化功能需求开始,一步步去完善。
基本功能需求
- 图标位置与地图上地理空间对应
- 支持数据的特异性,比如可以根据设备的类型、状态、尺寸等属性展示不一样的效果
- 图层可交互,即被鼠标选中和点击时能够有反馈
- 在地图上支持尽量多的图标数据展示,至少支持1W的数据量
原始数据
本次调试使用数据量为57183个数据。采用geoJSON标准,格式如下:
{
"type":"FeatureCollection",
"features":[{
"type":"Feature",
"geometry":{
"type":"Point",
"coordinates":[ 113.463338, 22.825134]
},
"properties":{
"deviceUid":"8104039040",
"icon":"Bracelet",
"color": "#00ff00",
"scale": 1.0
}
}]
解决问题
版本1:Sprite+SpriteMaterial
翻阅three.js的官方文档,我发现Sprite这个Object3D的子类似乎可以满足需求。Sprite是一种特殊的2D平面对象,它总是面向摄像机并且可以在3D场景中进行渲染,通常用于显示图标、徽标或者粒子效果等元素。
于是我马上进行了尝试,通过高德地图customCoords.lngLatsToCoords解决地图坐标转换问题;可以使用Spirete.scale 和 SpriteMaterial给数据设置不同的尺寸和材质来实现数据特异性;使用raycast射线检测做鼠标选中,并通过调整选中对象的scale实现反馈;至于该方案能够支持的数据量,就直接测试看效果。
下面是核心代码实现:
// 数据空间坐标[[x,y],[x,y]...]
_data = []
// 当前选中的对象
_currIcon = null
/**
* 初始化材质
* @return {*}
*/
initMaterial () {
const { icon } = this._conf
const texture = new THREE.TextureLoader().load(icon.src)
const material = new THREE.SpriteMaterial({
map: texture,
transparent: true,
depthTest: true
})
// 透明度贴图
if (icon.alphaMapURL) {
material.alphaMap = new THREE.TextureLoader().load(icon.alphaMapURL)
} else {
material.alphaMap = texture
}
// 图标颜色
if (icon.color) {
material.color = new THREE.Color(icon.color)
}
// 叠加发亮效果
material.blending = THREE.CustomBlending
material.blendDst = THREE.OneFactor
this._material = material
return material
}
/**
* 渲染图标,共享1种材质
* @protected
*/
createMesh () {
const { scene } = this
const [w, h] = this._conf.icon.size
for (let i = 0; i < this._data.length; i++) {
const [x, y] = this._data[i]
const sprite = new THREE.Sprite(this._material)
sprite._attrs = this._features[i]
sprite.scale.set(w, h, 0)
sprite.position.set(x, y, h / 2)
scene.add(sprite)
}
}
/**
* 处理拾取事件
* @param targets {Array} 拾取对象
*/
onPicked ({ targets }) {
const [w, h] = this._conf.icon.size
if (targets.length > 0) {
const mesh = targets[0].object
mesh.scale.set(1.5 * w, 1.5 * h, 0)
if (this._currIcon) {
this._currIcon.object.scale.set(w, h, 0)
}
this._currIcon = targets[0]
} else {
if (this._currIcon) {
this._currIcon.object.scale.set(w, h, 0)
}
this._currIcon = null
}
}
Sprite方案的呈现效果:随着地图缩放,渲染数据量上升,帧率明显下降。
版本2: Points+PointMaterial
显然Sprite在数据量这个性能上遇到了瓶颈,一旦数据量上升性能下降非常明显;另外它的数据特异性和数据量性能两者是冲突的,增加特异性会影响性能,试图提高性能又必须牺牲数据特异性。
于是我们不得不寻找其他路径,在文档中发现了Points类,它恰恰是用来高效地渲染大量的3D粒子,也许可以解决数据量瓶颈的问题。
Points能够如此高性能的最大原因,是它默认只有1个几何体(大大减少drawcall),且仅用了1个顶点就能代表一个数据。
-
以下是Points的构造参数geometry的创建过程:
import CustomShader from '../shader/PointIcon' /** * 渲染图标,粒子系统方法,支持大数据量 * @protected */ async createMesh () { // 创建几何体 const geometry = this.generateGeometry() // 创建材质 const material = await this.generateMaterial() // 创建网格体 const particles = new THREE.Points(geometry, material) this.scene.add(particles) this._mesh = particles } /** * Points方案创建几何体 * @return {*} */ generateGeometry () { const geometry = new THREE.BufferGeometry() const vertices = [] const { icon } = this._conf const size = icon.size[0] const vColor = new THREE.Color() for (let i = 0; i < this._data.length; i++) { const [x, y] = this._data[i] // 顶点位置 vertices.push(x, y, 0) } return geometry } /** * 创建材质 * @return {*} */ generateMaterial(){ const { icon, sizeAttenuation } = this._conf const material = new THREE.PointsMaterial({ map: new THREE.TextureLoader().load(icon.src), size: icon.size, transparent: true }) return material } -
看看Points+PointMaterial方案最终的效果,高数据量基本不影响帧率,画面流畅。
然而Points配合PointsMaterial使用,自身有个无法避免的问题——数据特异性功能薄弱。官方提供的例子 虽然能够提供部分特异性,但是用多实例的方式实现的,会带来另外的数据管理和配置的问题,这显然也不是个理想方案。
版本3: Points+ShaderMaterial
后来通过跟AI尬聊以及翻阅官方示例,我们找到第三个方案 webgl_custom_attributes_points2,也许有助于解决特异性问题。我们可以在自定义着色器的材质ShaderMaterial里,通过attribute直接使用到每个数据的独特属性做渲染,这简直太棒了。
-
核心代码,创建网格体
import CustomShader from '../shader/PointIcon' /** * 渲染图标,粒子系统方法,支持大数据量 * @protected */ async createMesh () { // 创建几何体 const geometry = this.generateGeometry() // 创建材质 const material = await this.generateMaterial() // 创建网格体 const particles = new THREE.Points(geometry, material) this.scene.add(particles) this._mesh = particles } /** * Sprite方案创建几何体 * @return {*} */ generateGeometry () { const geometry = new THREE.BufferGeometry() const vertices = [] const sizes = [] const colors = [] const { icon } = this._conf const size = icon.size[0] const vColor = new THREE.Color() for (let i = 0; i < this._data.length; i++) { const [x, y] = this._data[i] // 顶点尺寸 const scale = this._features[i].properties.scale || 1 sizes.push(size * scale) // 顶点位置 vertices.push(x, y, size * scale / 5.12) // 顶点颜色 vColor.set(this._features[i].properties.color || '#ffffff') vColor.toArray(colors, i * 3) } // 所有顶点位置xyz geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3)) // 所有顶点颜色rgb geometry.setAttribute('customColor', new THREE.Float32BufferAttribute(colors, 3)) // 所有顶点尺寸 geometry.setAttribute('size', new THREE.Float32BufferAttribute(sizes, 1)) return geometry } /** * 创建材质 * @private */ async generateMaterial () { const { icon, sizeAttenuation } = this._conf const texture = await new THREE.TextureLoader().load(icon.src) const { vertexShader, fragmentShader } = CustomShader const material = new THREE.ShaderMaterial({ uniforms: { color: { value: new THREE.Color(icon.color || 0xffffff) }, pointTexture: { value: texture }, alphaTest: { value: 0.1 }, sizeAttenuation: { value: sizeAttenuation } }, vertexShader, fragmentShader, transparent: true }) return material } -
创建自定义ShaderMaterial材质这块需要重点讲讲,在做渲染的过程中,顶点着色器的每个顶点对应一个具体的数据,每个数据的position、customColor、size属性已经通过attribute 传入到着色器中。我们在顶点着色器中放大顶点的尺寸,并在片元着色器中进行图标纹理贴图和颜色的叠加,就可以得到每个数据的最终效果。
// 定义一个着色器对象,包含各种统一变量和着色器代码 const shader = { // 定义用于着色器的统一变量 uniforms: { // 点的统一颜色,可以与点的特异颜色叠加 color: { value: 'THREE.Color' }, // 用于渲染点的纹理贴图,可以与颜色叠加 pointTexture: { value: 'THREE.Texture' }, // 透明度测试阈值统一变量 alphaTest: { value: 0.9 }, // 指定点的大小是否因相机深度而衰减 sizeAttenuation: { value: true } }, // 定义顶点着色器代码 vertexShader: ` // 每个点的尺寸倍率属性 attribute float size; // 每个点的自定义颜色属性 attribute vec3 customColor; // 指定点的大小是否因相机深度而衰减 uniform bool sizeAttenuation; // 传递颜色到片元着色器的变量 varying vec3 vColor; void main() { // 将自定义颜色传递到片元着色器 vColor = customColor; // 将顶点位置转换到视图空间 vec4 mvPosition = modelViewMatrix * vec4( position, 1.0 ); if(sizeAttenuation){ // 根据到相机的距离计算点的大小 gl_PointSize = size * (512.0 / -mvPosition.z ); }else{ gl_PointSize = size ; } // 计算点在投影空间的最终位置 gl_Position = projectionMatrix * mvPosition; } `, // 定义片元着色器代码 fragmentShader: ` // 点的基础颜色统一变量 uniform vec3 color; // 基础颜色 // 点贴图统一变量 uniform sampler2D pointTexture; //点贴图 // 透明度测试阈值统一变量 uniform float alphaTest; //透明度阈值 // 获取每个点自定义颜色的变量 varying vec3 vColor; // 每个点的颜色 void main() { // 计算点的最终颜色,乘以基础颜色和自定义颜色 gl_FragColor = vec4( color * vColor, 1.0 ); // 将点贴图应用到点上 // gl_FragColor = gl_FragColor * texture2D(pointTexture, gl_PointCoord); // 将点贴图应用到点上,gl_PointCoord的值是从[0, 1]映射到[1, 0]的范围,需要把贴图y分量翻转 gl_FragColor = gl_FragColor * texture2D(pointTexture, vec2(gl_PointCoord.x, 1.0 - gl_PointCoord.y)); // 如果alpha值小于透明度测试阈值,则丢弃片元 if ( gl_FragColor.a < alphaTest ) discard; } ` } // 导出着色器对象 export default shader -
于是我们就可以看到兼顾了大数据量和特异性(尺寸、颜色)的效果,其实纹理贴图也可以作为可配置参数事先声明,那么就可以做到不同类型使用不同图片。
-
解决当前方案的交互性问题
在开发过程中其实还遇到另外一个问题,就是在实现“图层可交互”需求的时候,发现从光标处发射的raycast射线总是很难命中目标。排查了好久发现原因是作为Points的元素顶点,本身的可检测范围太小了只有1个像素点,在1920*1080的屏幕上要让粗细只有1像素的射线和1个像素的点产生碰撞说实话还是有很大难度。
所幸翻了下Raycast的文档发现了这样一个属性,可以通过调整raycast.params.Points.threshold 数值来修改射线和目标的碰撞精度,把threshold调整为10,表示当射线与目标顶点距离10个世界单位(在本案例是px)时便认为碰撞命中。
// threshold 默认值为1 this._raycaster.params.Points.threshold = 10.0这是调整后的效果,最终我们可以看到图层既满足了大数据量、可交互、又实现了数据特异性需求。
迭代拓展
由于最终版本采用的是Points方案,无论size设置多少,Point始终是个正方形的顶点。 能不能进一步再做优化,让数据支持更灵活的造型或者宽高尺寸配置,可以做更多“造型”的icon,更多“朝向”的icon。
其实也是可以的,我们沿用Points+ShaderMaterial的思路,把Points换成InstancedMesh就可以。
-
创建实例化网格体
const { icon } = this._conf // 数据量 const instanceCount = this._data.length // 创建材质 const material = new THREE.MeshBasicMaterial({ map: new THREE.TextureLoader().load(icon.src), transparent: true, alphaTest: 0.01, side: THREE.DoubleSide }) // 创建网格体 const [width, height] = icon.size const geometry = new THREE.PlaneGeometry(width, height) const instanceMesh = new THREE.InstancedMesh(geometry, material, instanceCount) this.scene.add(instanceMesh) this._mesh = instanceMesh -
数据实例化
// 实例化 const dummy = new THREE.Object3D() const vColor = new THREE.Color() // debugger for (let i = 0; i < instanceCount; i++) { const [x, y] = this._data[i] const { color, scale = 1 } = this._features[i].properties // 重置 dummy 对象的状态 dummy.rotation.set(0, 0, 0) // 定位 dummy.position.set(x, y, height * scale / 2) // 翻转90度为立面 dummy.rotateX(Math.PI / 2) // 尺寸 dummy.scale.set(scale, scale, scale) dummy.updateMatrix() // 将对dummy的调整复制到具体的元素网格中 instanceMesh.setMatrixAt(i, dummy.matrix) // 给实例添加颜色,取特异性颜色、或统一颜色、或者默认颜色 instanceMesh.setColorAt(i, vColor.set(color || icon.color || '#ffffff')) } -
交互性实现有所区别
/** * InstancedMesh方案处理拾取事件 * @param targets */ onPicked ({ targets }) { if (targets.length > 0) { const index = targets[0].instanceId if (this._currIcon !== index) { // 旧选中者恢复原状态 if (this._currIcon !== null) { this.updateInstancedMatrix(this._currIcon) } // 声明新选中者 this._currIcon = index // 新选种者改变状态 this.updateInstancedMatrix(this._currIcon, 1.2) } } else { // 旧选中者恢复原状态 this.updateInstancedMatrix(this._currIcon) this._currIcon = null } } /** * 更新指定实例的变换矩阵 * @param index * @param scale */ updateInstancedMatrix (index, scale = 1) { if (index == null) { return } const height = this._conf.icon.size[1] const _scale = this._features[index]?.properties?.scale || 1.0 const [x, y] = this._data[index] const matrix = new THREE.Matrix4() // 定位 matrix.makeTranslation(x, y, height * _scale / 2) // 翻转90度为立面 matrix.multiply(new THREE.Matrix4().makeRotationX(Math.PI / 2)) // 尺寸 matrix.scale(new THREE.Vector3(_scale * scale, _scale * scale, _scale * scale)) // 更新变化矩阵 this._mesh.setMatrixAt(index, matrix) this._mesh.instanceMatrix.needsUpdate = true } -
我们来看看最终效果
这样扩展一下思路,似乎就变成了3D点标记图层;再泛化一下,我不仅可以做点标记,也可以换成模型、动态自定义模型,这样一来不就可以实现更多的需求了
事实上经过这么一扩展,就实现了之前的文章分享到的海量模型渲染方案。忽然有一种殊途同归、融汇贯通的体验是怎么回事。
总结归纳
经过以上几组实践,我对Three.js中几种Object3D子类Sprites、Points、InstancedMesh的特性进行了深入的比较和评估。
Sprites基于纹理的2D渲染对象,它们具有简单易用、渲染效率高等优点,非常适合用于UI界面或者粒子特效。
Points则是基于点的渲染方式,特别适合用于渲染大量的散点数据,例如星空、云层等。Points的渲染速度非常快,但是只能呈现简单的点状效果。
InstancedMesh则是用于渲染大量相同几何体的高效方式。它通过一次性传输所有实例的数据到GPU,大大减少了CPU和显卡之间的数据交互,非常适合用于渲染大规模的场景元素,如树木、建筑物等。
通过对这些特性的深入认识和评估,我相信在今后的开发中,我们能够更加得心应手地选择合适的Three.js渲染对象,为各种复杂的可视化需求提供高效、优质的解决方案。