原文:Robust Polyline Rendering with WebGL -- Cesium
原文发表时间:2013 年 4 月 22 日
原作者:Cesium 团队 Dan Bagnell
1. 如果使用 WebGL 原生线类型渲染
使用 WebGL 原生的 LINES
、LINE_STRIP
或 LINE_LOOP
渲染线会有很多问题,例如,如果你的机器运行的 WebGL 底层是 ANGLE,那么最大线宽(像素)只能是 1.
还有,在 ANGLE 为底层的 WebGL 下,在公共顶点处是没有拐弯效果的,如下图所示:
使用「miter」接头类型绘制的线应该是这样的:
哦对,上面这俩图是有线的外边线效果的,如果没有「z-fighting」的前提下,要实现外边线,那么要使用模板缓冲技术渲染三次。
2. 我们(Cesium)的做法
译者注1,明确,折线段(Polyline)的每一段叫作线段(Segment)。
绘制折线,Cesium 官方团队使用的方法是为每段线段绘制一个面朝屏幕的四边形。
WebGL 没有几何着色器(Geometry Shader),我们只能把折线的顶点复制一遍,然后在顶点着色器里用宽度值来偏移顶点,形成屏幕坐标系的四边形。
为了偏移顶点,首先要知道左右顶点的位置。因为没有几何着色器,所以需要额外的顶点属性(VertexAttribute)来告诉顶点着色器相邻顶点。
译者注2,WebGL 比较鶸,GPU 编程是并行的,它没有办法获取其它顶点或者整块 VertexBuffer,只能把相邻的顶点坐标通过顶点属性传给 VS。
一旦有相邻的顶点坐标,就能在 VS 中计算出前后线段的方向,进而知道线段的垂直方向,就可以沿着垂直于线段的方向偏移当前被重复的两个顶点坐标。
对折线的所有顶点进行如上所述的计算、渲染后,就能得到文章开头使用线宽绘制的线段效果了
译者注3,实际上就是把无宽度概念的抽象线段用多边形绘制。
到此,虽然具备了宽度,但是在顶点处仍然没有拐弯效果,即文章开头的图1。想要达到图2的效果,要把 VS 中顶点计算转换到屏幕空间中,就不能在拐点处沿着线段的法线方向偏移,而是要沿着屏幕坐标系的前后两个向量和的方向进行偏移。这些向量加减等需要一些线性代数、三角函数等简单知识,后面会讲。
CesiumJS 会把顶点属性保持 8 个以内(因为 WebGL 1.0 时,规定顶点属性最大个数最少要是 8 个),以保证跨设备 WebGL 的兼容性。
译者注4,我在自己电脑上看了看
gl.getParameter(gl.MAX_VERTEX_ATTRIBS);
的值,是 16,Intel i5-13600 + Windows 11 + Edge 120 浏览器
对于每个顶点坐标,就至少需要 4 个 vec3
------ 其中,2 个 vec3
用于表示 3D 坐标,2 个则用来表示 2D 的。这样的用法 CesiumJS 特有的。之所以需要用两个 vec3
来表示一个 3D 位置,是因为单个 vec3
在顶点着色器中不足以表达双精度浮点(vec3 是单精度浮点),容易在距离较近时发生坐标抖动问题。
关于浮点数精度问题,可以参考 Precisions, Precisions 这篇资料。
如果再加上每个顶点的前后两个顶点数据,在顶点着色器里就需要 12 个顶点属性,这就有可能在一些平台中发生问题(譬如这个平台对 WebGL 的实现就只支持最多 8 个顶点属性)。
将顶点属性数量限制在 8 个之后,有一个解法,那就是只向顶点着色器传递 4 个顶点坐标属性,以及到相邻顶点的方向,CesiumJS 需要 3D 和 2D 的方向向量,减去 4 个顶点坐标属性后,还有 4 个顶点属性就可以分配给 4 个 vec3
来表示方向。
但是,这还可以进一步优化。
Cesium 团队希望在顶点属性中传递更多信息,例如纹理坐标、线宽等,那么就需要使用一种技术进一步减少或者说压缩顶点属性,最终只需要 2 个 vec4
即可表示方向。这种技术叫作「压缩单位向量」,在后面的章节会有详细实现。
但是,在压缩法线向量时,遇到了精度问题,当接近折线端点且距离另一个相邻端点超过 9w 米的时候,端点的平移会发生抖动:
有一种办法是对超长距离的端点之间进行增稠细分。但是这个办法对于下图所示的 Geoeye 1 和 ISS 之间的连接线来说,从 2 个顶点增加到了 50 个顶点。这就带来数据的臃肿问题了。
所以,Cesium 官方团队决定使用 12 个顶点属性,而不是增稠顶点数量。
回到顶点着色器,在屏幕坐标系下对顶点坐标进行沿着向量偏移后,要把偏移后的屏幕坐标转回顶点着色器应喂给下一步用的裁剪坐标系下。
屏幕坐标系下偏移完的坐标值应是一个 vec4
,它具有 x、y、-z 和恒为 1.0 的 w。之所以使用 -z,是因为视空间(相机坐标系)之后的空间(包括它自己)中,视线方向都沿着 -z 轴。w 分量用于进行透视除法,使得物体有近大远小的效果。w 分量设为 1.0 是因为希望无论视角咋样,线的宽度都保持不变,即放弃近大远小。
在上面的计算中,顶点着色器是先一步到位在屏幕坐标系(也就是完成了视口变换)下进行了计算,然后再反转视口变换、透视除法等步骤还原回顶点着色器本该往下一阶段传的裁剪坐标。
但是,把 w 设为 1.0,放弃近大远小又会产生另一个问题。当折线有一部分在近平面之外、一部分在近平面之内,也就是相交时,如下图所示:
调整相机往前一点,这根线就迅速变成这样:
很容易发现右边的线突然从下面指向屏幕的上面,很突兀。原因需要查看一些古老的资料:Clipping using homogeneous coordinates
为了解决这个问题,需要在反算到裁剪坐标之前,把线裁剪到近平面,具体而言,就是把近平面之外(Frustum 之外)的部分切断,保留与近平面的交点。举例,如下图所示应该怎么办?
左图,两条线段共享蓝色圆圈处的顶点,顶点在近平面之外。这两根红色线段与近平面相交于绿色圆圈处。那么,应该把蓝色顶点裁剪到哪一个绿色圆圈的交点处呢?Cesium 团队已经通过算法实现了这个问题,即平移共享点到近平面。
线段的共享顶点,也就是图中蓝色圈处的顶点,其实是有相同的 4 份的,前后的线段都持有 2 份,用于平移。所以同理的,Polyline 的起始端点则复制了 2 次。
除此之外,还需要知道这个顶点坐标属于哪根线段,才能把它裁剪正确。
译者注5,左图蓝色圆圈的顶点其实被复制了 4 次,即被两根线段各持 2 个。在顶点着色器中,虽然只会处理 1 个,但是会知道这个点坐标的上一个和下一个,以及它应该被平移的方向。如果它被近平面裁剪,那么就把它平移到近平面即可。
右图是另一个情况,如果有两个蓝色圆圈处的顶点被甩在了近平面之外,也就是说有完整的一根线段被 Frustum 剔除、裁剪,那么啥都不用做,让 WebGL 把这根在近平面之外的线段完全裁剪掉即可,压根就没什么问题。
译者注6,上面这些计算都发生在顶点着色器,还没发生裁剪,所以可以加一点逻辑完成最终效果
3. 顶点着色逻辑细节
3.1. 在屏幕坐标系下偏移顶点
下图用中间黑色的折线来绘制出红色的"折线",在屏幕坐标系下,黑色的折线被偏移了等宽的距离。
下图是拐点内侧(锐角)的细节,设绿色向量为 <math xmlns="http://www.w3.org/1998/Math/MathML"> u u </math>u,黑色向量为 <math xmlns="http://www.w3.org/1998/Math/MathML"> v v </math>v,显然,拐点要沿着未知的向量 <math xmlns="http://www.w3.org/1998/Math/MathML"> u u </math>u 进行偏移,而 <math xmlns="http://www.w3.org/1998/Math/MathML"> u u </math>u 就是要在顶点着色器中计算的。
根据当前顶点和前后顶点的坐标,轻易可以计算得到 <math xmlns="http://www.w3.org/1998/Math/MathML"> v v </math>v 的方向向量。知道 <math xmlns="http://www.w3.org/1998/Math/MathML"> v v </math>v 后,就可以算出 <math xmlns="http://www.w3.org/1998/Math/MathML"> u u </math>u,它就是这个拐角的角平分线方向。
现在只需要算出 <math xmlns="http://www.w3.org/1998/Math/MathML"> u u </math>u 的长度即可,根据三角函数不难得出:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> ∣ u ∣ = w i d t h s i n ( a ) |u|=\frac{width}{sin(a)} </math>∣u∣=sin(a)width
对于任何向量 <math xmlns="http://www.w3.org/1998/Math/MathML"> p p </math>p 和 <math xmlns="http://www.w3.org/1998/Math/MathML"> q q </math>q,有:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> ∣ ∣ p × q ∣ ∣ = ∣ p ∣ ⋅ ∣ q ∣ ⋅ ∣ s i n ( a ) ∣ ||p×q||=|p|·|q|·|sin(a)| </math>∣∣p×q∣∣=∣p∣⋅∣q∣⋅∣sin(a)∣
如果我们把 <math xmlns="http://www.w3.org/1998/Math/MathML"> u ^ û </math>u^ 和 <math xmlns="http://www.w3.org/1998/Math/MathML"> v ^ v̂ </math>v^ (译者注:这种字母一般就是指单位向量)当作 xOy 平面上的三维向量,那么把它俩代入即可。
由于 <math xmlns="http://www.w3.org/1998/Math/MathML"> u ^ û </math>u^ 和 <math xmlns="http://www.w3.org/1998/Math/MathML"> v ^ v̂ </math>v^ 是单位向量,且在 xOy 平面上,那么 z 分量就是 0,所以可简化为:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> s i n ( a ) = ∣ u ^ . x ⋅ v ^ . y − u ^ . y ⋅ v ^ . x ∣ sin(a)=|û.x·v̂.y-û.y·v̂.x| </math>sin(a)=∣u^.x⋅v^.y−u^.y⋅v^.x∣
GLSL 代码如下:
glsl
// 译者注,计算端点处的半角正弦值
float sinAngle = abs(u.x * v.y - u.y * v.x);
// 除以正弦值得到角平分线的向量长度,并存储在 width 中
width /= sinAngle;
// 根据方向向量、方向符号、角平分线长度、高分辨率缩放比计算出世界坐标下的偏移量
vec2 offset = direction * directionSign * width * czm_highResolutionSnapScale;
// 完成偏移,并使用正射投影完成裁剪坐标的计算
gl_Position = czm_viewportOrthographic * vec4(positionWC.xy + offset, -positioinWC.z, 1.0);
若 sinAngle
非常小(逼近 0),容易出现尖锐角问题。还要确保拐点处的两个顶点能按正确的方向平移(此例子中即 <math xmlns="http://www.w3.org/1998/Math/MathML"> u u </math>u 或 <math xmlns="http://www.w3.org/1998/Math/MathML"> − u -u </math>−u 方向)。
译者注7,这段代码在最新版的 CesiumJS 已经改掉了,
offset
的计算为vec2 offset = leftWC * expandDirection * expandWidth * czm_pixelRatio;
,在PolylineCommon.glsl
中能找到,大体思路还是保持十几年前的。
3.2. 裁剪至近平面
同上文解释的一样,要把线段在顶点着色器中提前裁剪到近平面,而不是等 WebGL 自己做。下面这个函数就实现了这个思路:
glsl
void clipLineSegmentToNearPlane(
vec3 p0,
vec3 p1,
out vec4 positionWC,
out bool culledByNearPlane)
{
culledByNearPlane = false;
vec3 p1ToP0 = p1 - p0;
float magnitude = length(p1ToP0);
vec3 direction = normalize(p1ToP0);
float endPoint0Distance = -(czm_currentFrustum.x + p0.z);
float denominator = -direction.z;
if (endPoint0Distance < 0.0 && abs(denominator) < czm_epsilon7)
{
// the line segment is parallel to and behind
// the near plane
culledByNearPlane = true;
}
else if (endPoint0Distance < 0.0 && abs(denominator) > czm_epsilon7)
{
// ray-plane intersection:
// t = (-plane distance - dot(plane normal, ray origin))
// t /= dot(plane normal, ray direction);
float t = (czm_currentFrustum.x + p0.z) / denominator;
if (t < 0.0 || t > magnitude)
{
// the segment intersects the near plane,
// but the entire segment is behind the
// near plane
culledByNearPlane = true;
}
else
{
// segment intersects the near plane,
// find intersection
p0 = p0 + t * direction;
}
}
positionWC = czm_eyeToWindowCoordinates(vec4(p0, 1.0));
}
函数中的注释描述了发生裁剪(或剔除)的条件,这个函数以近平面的法线为 vec3(0.0, 0.0, -1.0)
、且距离原点的距离就是 czm_currentFrustum.x
进行了简化计算。
译者注8,这个函数在 CesiumJS 1.114 仍然存在于
PolylineCommon.glsl
中,只是代码略微有变,注释还是有的而且相当详细,这里就不贴了,需要花点时间研究,就是一些简单的代数几何计算。
3.3. 编码/解码单位向量
当被限制到 8 个 VertexAttribute 时,Cesium 团队使用球形贴图变换(Spheremap Transform,在地图学里又叫做兰伯特方位等积投影)来对当前顶点的上一个、下一个顶点的单位向量进行编码。这样,两个 vec3
向量就能编码到一个 vec4
.
下面是把两个单位向量压缩到两个分量的 JS 代码:
javascript
/**
* @param {Cartesian3} cartesian
*/
function encode(cartesian) {
const p = Math.sqrt(cartesian.z * 8 + 8);
const result = new Cartesian2();
result.x = cartesian.x / p + 0.5;
result.y = cartesian.y / p + 0.5;
return result;
}
在 GLSL 中对应的解压缩代码(用于顶点着色器):
glsl
vec3 decode(vec2 enc) {
vec2 fenc = enc * 4.0 - 2.0;
float f = dot(fenc, fenc);
float g = sqrt(1.0 - f / 4.0);
vec3 n;
n.xy = fenc * g;
n.z = 1.0 - f / 2.0;
return n;
}
有关压缩单位向量的不同方法,可以参考这篇:Compact Normal Storage for Small G-Buffers.
4. 译者总结
CesiumJS 在渲染 Polyline 这事儿上下了不少功夫。在本科 GIS 中,把线生成面,而且是按宽度生成,是一个非常入门的工具:缓冲区分析。
但是,在如履薄冰的图形渲染管线中,要把单纯表示线的顶点们通过平移的手段"变"成面,而且还要保持接头的样式(此例用的是斜接接头),还要考虑 WebGL 的裁剪问题、WebGL 顶点属性不够用的问题,就变得比较复杂了。好在图形学大佬们早在数十年前就搞定了这些问题。
综上,可以得出 CesiumJS 渲染空间折线的技术难点和解决方法:
- 使用绘制面的方法替代原生 WebGL 线不能渲染线宽的问题
- 使用端点复制成两个,并沿着特定方向、指定宽度平移的办法,实现渲染面
- 为了保证折线在屏幕上始终宽度如一,需要在顶点着色器中先一步到视口坐标系下进行偏移,再反算到裁剪坐标,以衔接 WebGL 渲染管线
- 为解决有端点被近平面裁剪的问题,就需要把端点平移到近裁剪面上
- 在端点处,如果相邻的前后点都存在,那么它是一个中间点,就要处理接头问题,CesiumJS 目前实现的是斜接(即「miter」型)类型
- 因为 WebGL 没有几何着色器,所以复制顶点的工作在 JS 这边让 CPU 完成计算
纵观 WebGL 发展的这十几年,对 Polyline 的渲染已经有很多资料,然而 CesiumJS 却停留在当年的效果,对斜接接头过于锐角的情况、抗锯齿的优化都没有改进,这点是比较遗憾的,希望只是 CesiumJS 在憋大招吧。