Deck.gl绘制三维地下管道
1.背景
写这个文档不是为了让大家来抄代码,而是给出绘制地下管道的思路和理论基础,坦白的说授之于鱼不如授之于渔,当你理解底层原理之后画个管道不是什么难事还有其他很大的想象空间,技术上的原理我尽量写详细一点,让开发者能够明白里面到底在干啥。
首先由于项目需要,我们想要在WebGIS
上实现地下管道的绘制,但是不同于之前依靠模型或者二维线条实现为了完整表现管道的纵横走向同时具备大规模渲染能力所以需要以某种数据驱动的方案进行管道的绘制。本质上来讲核心点在于依靠数据自动生成管道,并且具备高性能渲染能力,在这样的要求之下能够我们能够选择的技术路径又回到deck.gl
了,之前也说过这玩意非常强大但是文档很水同时国内基本上没有什么参考资料所以开始折磨了。
2.原理
2.1如何创建管道
首先无论deck.gl
搞得有多花哨,绘制工作肯定是通过webgl
去实现的这一点毋庸置疑,所以我们首先需要确定在webgl
体系下如何去创建一个管道,接触过的都知道管道与一个立方体在底层上没有什么不同,都是通过顶点、法线、UV等信息构建三角面形成的几何体是opengl
(webgl
)体系下三维模型的基础数据,就参数而言包括如下信息:
positions
:几何体每一个顶点的信息,每有一个点对应的顶点着色器就会运行一次;normals
:每一个顶点的法线,这里主要用于进行光照运算;uv
:每一个顶点的UV坐标,这里主要用于贴图纹理;indices
:三角面的绘制索引,这里是确定哪些顶点构成三角面;
其中的indices
参数可能不好理解,如果你是新手我大概说一下,它是一个整形数组(Int
),大概这个样子:[0, 1, 2, 2, 3, 0]
,里面的每三个数构成一个三角面,每个数字代表的是顶点数组的index,及就是三个点组成一个三角面,所以举例的数组表达的意思是:第一个点,第二个点,第三个点构成一个三角面同时第三个点、第四个点、第一个点也构成一个三角面。这里还有一个问题那就是三角面有方向之分,及一个面是正面一个反面,这里就是组成点的方向:顺时针情况下上面是正面反之亦然。
相信到这里都会发现计算indices
比较麻烦,所以在webgl
对于三角面有三种绘制方案:
- GL_TRIANGLES:这种情况各个点构成的三角面需要通过
indices
指定,也就是常规方案; - GL_TRIANGLE_STRIP:这种情况
webgl
会自动填充indices
,每一个点与向下两个点组成一个三角面; - GL_TRIANGLE_FAN:这种情况也会自动填充
indices
,以第一个点为中心后续每两个点与第一个点组成三角面;
对管道而言至此铺垫完毕,可以进行管道的绘制了,首先管道肯定是一节一节构成的,每一节绝对是一个圆柱体,一系列圆柱体收尾相连不就是一个完整的管道了吗?对于输入数据而言肯定也是一系列的点坐标进而形成一堆收尾相连的线段最终形成圆柱就是绘制管道的思路了,所以对于最终的每一节管道本质上就是如何把一个线段转为圆柱体而已,绘制管道问题本质上就是将一个线段变为圆柱而已!
既然入参是一个线段那么必然有起点和终点,既然是绘制管道同时也绝对有半径这些便是管道的基础参数。一个管道的横截面是一个圆形,对于一个圆柱体来说最简化的方案肯定是所有顶点都在两个圆形顶面上,所以我们只需要绘制一个顶点数量足够的长方形然后卷起来便能够得到一个圆柱(这动图是通过manim
制作的,manim是YouTube上的数学大神3B1B开发的开源工具,能够通过python非常简单的完成数学可视化,我线性代数就是在他这里重修的😜,实力推荐B站上也有:地址,这个工具最NB的是能够进行一些数学上的验证非常高效):
很明显我们制作管道的表面最简单的方案是GL_TRIANGLE_STRIP本质上就是画一个长方形而已,然后只需要将顶点移动到组成圆柱的位置即可,为了将顶点移动到组成圆柱效果最终需要使用极坐标计算出圆柱轴线某一个半径下的坐标,这里方式是通过上一个管道与当前管道方向向量的叉乘得到垂直与轴线的法向量,这个法向量就可以作为第一个点需要移动的方向,然后将这个法向量做绕轴线旋转一定的角度就能够得到一个圆周上各个点需要移动的方向,然后只需要将点按照对应法向量方向移动半径长度我们就能够得到圆柱了:
应该是说的比较清楚了最后整理一下步骤如下:
- 得到表示管道轴线的向量数组
- 通过将当前管道向量与上一个不共线的管道向量叉乘得到顶点移动的初始方向
- 通过需要选择的角度将移动初始方向的向量绕当前管道向量旋转对应的角度
- 的到管道几何体
2.2管道连接处如何处理
按照上面的方案得到各个管道的几何体之后还有一个问题,那就是各个管道之间没有连接管道的组件最终表现比较鬼畜,大概是这个样子:
其实原因很简单我们仅仅创建了管道,并且已端点作为管道起点且没有封口那必然是这个样子,这里我能想到大概两个方案,第一是给端点添加细分将当前管道与上下两个管道的顶点移动到相同的位置,显然非常复杂而且由于旋转方向来自法向量及导致顶点很难对其,最终会是一个比较难的数学问题(比最后一道大题还难);另一种就是简化处理,只需要在连接处的端到放一个半径一样的球就行了,这种方案极其简单也很好理解,看起来也问题不大,只是可能性能不算优秀不过够用就行要什么自行车:
2.3DeckGL自定义图层
首先必须要搞清楚DeckGL
自定义图层的逻辑与方式,这一块官方文档写的极其粗糙大部分都是根据基本描述然后结合经验与代码验证推导出来的,心累。总的来说需要继承基础的Layer类然后有下面几个函数是需要重写的:
- getShaders:这个函数是在对自定义图层的着色器进行定义,包括指定两种着色器并且指定一下
DeckGL
自带的着色器模块功能集,有了对应的模块后续在着色器之中就能够调用对应的函数。 - initializeState:这个函数是初始化属性,也就是WebGL里面需要传入着色器的
attribute
。 - updateState:这个不需要怎么解释了,当需要更新缓存区时调用,我们需要在这个函数里面写入更新逻辑。
- draw:这个是绘制前调用,每一帧都会调用,主要目的是为了更新传入着色器的
uniforms
。
继承基础Layer类之后将这些函数重新你就可以得到一个能够使用DeckGL
的自定义图层,但是这里有几个关键项需要重点理解才行:
- 构建图层时传入的data是所有的数据,对于每一个几何体而言通过是通过函数对data数据拆分来的,也就是说
DeckGL
本身其实不是在图层内部将所有顶点放在同一个几何体绘制的,通过函数分组将其分给每一个几何体,然后单独绘制简单几何体即可,只不过这些几何体存在不同数据所以表现不同(所以我们才能将管道分为一节一节进行绘制)。 DeckGL
在创建自定义图层时要求你编写完整的着色器代码,但是大部分涉及到投影、坐标系转换、光照、拾取等功能都已经封装到着色器模块之中了,最终你在写着色器代码时并不是从零开始。- 由于浮点精度问题
DeckGL
引入的着色器模块之中有一个project32
模块,传入经纬度数据到着色器时需要指定fp64: this.use64bitPositions()
表示这个属性是双精度浮点,然后在着色器之中分为高低位方式以获取完整坐标数据。
2.4DeckGL着色器模块
2.4.1投影
首先是坐标系问题,在DeckGL
着色器之中的坐标系狭义来说可以分为四个:
World
:世界坐标系,这里的数值就是经纬度与海拔,这个坐标系本质上是墨卡托坐标系,所以其内部空间是非线性坐标,一般只用于作输入传入,几何运算与线性变换都不在这个坐标系处理。Common
:公用坐标系,这里本质上就是在讲墨卡托坐标进行转换之后的三维坐标系,这个坐标系是线性坐标系,绝大部分几何运算与线性变换都可以在这个坐标系内部完成。Pixel
:屏幕空间坐标系,及是相机裁减空间在屏幕上的投影坐标,显然这是二维坐标系,常用于作一些固定像素大小的图形,比如文字图例之类的。Clipspace
:裁减空间坐标系,这里本质上是相机裁减空间也就是顶点最终输出的坐标系,这个坐标系也不是线性的,只能作为输出使用一般不进行几何运算。
各个投影矩阵之间的转换模块内部都定义了功能函数可以在着色器内部之间调用,这里可能是DeckGL
的核心部分,这里的文档写的算是简单明了,太难得了。
2.4.2光照
很显然DeckGL
有自己的光照系统,虽然他让你完成整个着色器的编写,但是也没有丧心病狂到让你自己去写个平行光(虽然不难但是恶心),只需要在自定义图层之中加入gouraudLighting
模块就可以在顶点着色器之中完成光照传递(如果想要在片元里面搞就是phongLighting
模块),对自定义图层应用模块功能之后就可以在着色器内部之间调用光照函数:
// 计算光照颜色 vec3 lightColor = lighting_getLightColor(color.rgb, project_uCameraPosition, geometry.position.xyz, geometry.normal); // 赋值传递给片元着色器 vColor = vec4(lightColor.rgb, instanceColors.a);
这里我使用的是gouraudLighting
(逻辑上来说性能比片元好一些),所以这段代码发生在顶点着色器,其中的color
是几何体渲染的颜色,project_uCameraPosition
是模块自带的变量无需声明直接使用,传入几何体顶点与法线就不用说了这是计算光照必须的,如果完全不懂可以参考这个:WebGL光照,vColor
是传递给片元的varying
变量。这里还有两个点需要注意一下,如果要启用光照效果则自定义图层必须开启材质属性:Props.material = true
。
2.4.3点选/拾取
DeckGL
在点选或者拾取功能的实现算是当年OpenGL
的一个经典案例。他其实是这么实现的:首先给每一个几何体一个不同且唯一的颜色信息(这个颜色信息不渲染)然后在着色器内部通过点选的屏幕空间坐标获取这个颜色信息也就知道了点选的是哪一个几何体。这样的做法显然性能很好不需要进行空间坐标系转换运算同时获取的颜色值无需经过任何处理便能找到对应的几何体,缺点就是无法穿透点选,对于GIS应用而言性价比显然高于射线法。实现上分为几步:
给几何体唯一颜色信息,及给一个几何体的属性属性假定叫做customPickingColors
,在initializeState
函数之中:
initializeState() { const attributeManager = this.getAttributeManager(); attributeManager.addInstanced({ customPickingColors: { size: 3, type: GL.UNSIGNED_BYTE, update: ()=>{ const { data } = this.props; const { value } = attribute; let i = 0; for (const object of data) { const pickingColor = this.encodePickingColor(i); value[i * 3] = pickingColor[0]; value[i * 3 + 1] = pickingColor[1]; value[i * 3 + 2] = pickingColor[2]; i++; } } } }); }
当然更新缓冲区函数也需要更新属性,然后在顶点着色器的最后一行加入:
picking_setPickingColor(customPickingColors);
在片元着色器的最后一行加入:
gl_FragColor = picking_filterPickingColor(gl_FragColor);
2.5裁减问题
这里的问题其实之前也遇到过,但是当时不算是什么问题就没有去深入研究,问题是这样,本来所以GIS元素都在地上,所以DeckGL
为了性能考虑就将裁减空间的边界设置的很极限,使地表元素刚到边界就超出了相机的farZ
,一旦我们将管道放到地下之后farZ
的值肯定就不够用了,靠近边缘的部分会被裁减到,表现如图:
这里我初步想法是从Camera
对象入手看看有没关于相机裁减空间的设置,在DeckGL
之中有一个叫做MapView
的视图对象,这里也就是我们GIS使用视图对象,里面有一个参数叫做farZMultiplier,这就没有什么好说了直接修改即可。
2.6最大仰角问题
这个是老生常谈了,现在基于maplibre-gl
终于实现了,2022年1月15日发布的2.0.1正式版完善这个特性,至此完全MIT开源基础上实现了最大85度的俯仰角。同时在2.0.1存在一个渲染问题(issues#718),解决方案社区也给出来了,在构造地图的时候添加localIdeographFontFamily: null
参数即可修复,由于maplibre-gl
底层完全来自于mapbox-gl
的V1版本所以在框架兼容上没有什么问题,大部分功能使用上也是完全相同,同时没有任何法律风险。
3.总结
本来都想把代码也开源了,但是转念一想这是我给公司项目写的代码,所以忍了,不过我写博客的风格向来不是直接给源码,都是从底层原理入手进行基础的知识分享。相信聪明的你通过本文的这么详细的原理解释应该能够写出相应的效果。
本人現正在使用deck.gl, 今看到您的成果, 真的很想學習相關代碼, 不知是否能提供多一點的code ?
给你源码等于是在害你,根据我给的逻辑自行编写吧,这都看不懂的话换个行业发展吧