webgl投影矩阵 demo_在 WebGL 中绘制直线

b53f73f8da53745fbc680f8c53c9d355.png

之前我们介绍了「在 WebGL 中绘制地图(多边形篇)」,本文会介绍关于直线的绘制方法,大致包含以下内容:

  • 直接使用原生 gl.LINES 的问题
  • 使用沿法向拉伸后三角化的方法绘制直线
  • 接头样式、反走样等常见优化手段
  • Cesium、Mapbox、GeoJS 等成熟引擎的实现

项目地址:

https://github.com/xiaoiver/custom-mapbox-layer/blob/master/src/layers/Line3DLayer.ts​github.com

gl.LINES 存在的问题

在一些场景下,尤其是涉及到地理信息的展示,直接使用原生的 gl.LINES 进行绘制存在一些问题:

  • 线宽无法设置,Chrome 下试图设置 lineWidth 会得到警告,相关 ISSUE :
MDN :As of January 2017 most implementations of WebGL only support a minimum of 1 and a maximum of 1 as the technology they are based on has these same limits.
  • 无法定义相邻线段间的连接形状 lineJoin 以及端点形状 lineCap

因此我们得考虑将线段转换成其他几何图形进行绘制。

沿法向拉伸

常用的做法是沿线段法线方向进行拉伸后三角化。例如下图中线段两个端点分别沿红色虚线法向向两侧拉伸,形成 4 个顶点,三角化成 2 个三角形。

64833f5bbee45e961e8e0b5beec2ae3c.png
沿原始线段法向拉伸进行三角化

这样实现起来很容易,例如 deck.gl 的 LineLayer :

a7b2d98c918ce3712418e986cf0e66a8.png
deck.gl - LineLayer

但是很明显,我们需要在相邻线段的连接处进行处理。

连接接头

在 Canvas API 中可以通过 lineJoin 指定线段接头处的连结方式,同样 SVG 中也有 stroke-linejoin 属性。从上到下分别为 rounded、bevel 和 miter。

4a7d6158df26f2a90ea20eb83a69258f.png
lineJoin: rounded、bevel 和 miter

我们以 miter joint斜接接头为例 ,看看在 WebGL 中如何实现。以下思路来自「Smooth thick lines using geometry shader」 。

首先需要计算切线方向 t,然后计算斜接接头方向 m 为 t 的法线方向,最后得到线宽(红色虚线)投影到接头方向的长度:

f149298579c543b24f0b27776014f02f.png
// https://github.com/mattdesl/polyline-miter-util/blob/master/index.js#L9-L20
function computeMiter(tangent, miter, lineA, lineB, halfThick) {
    // 计算切线
    add(tangent, lineA, lineB)
    normalize(tangent, tangent)

    // 计算接头方向
    set(miter, -tangent[1], tangent[0])
    set(tmp, -lineA[1], lineA[0])

    // 半线宽投影到接头方向的长度
    return halfThick / dot(miter, tmp)
}

在 vs 中,根据 miter 计算点的偏移量:

// https://github.com/mattdesl/three-line-2d/blob/master/shaders/basic.js
uniform float thickness; // 线宽
attribute float lineMiter; // miter 长度
attribute vec2 lineNormal; // normalize 之后的法线方向

vec3 pointPos = position.xyz + vec3(lineNormal * thickness / 2.0 * lineMiter, 0.0);

斜接接头还存在一个很明显的问题,如果连结处的夹角很小,就会导致计算出的 miter 无限大,视觉效果很不好。例如下图:

d49bdec690dce6e7635da5eb3c290388.png
相邻线段夹角很小导致 miter 长度过长

因此我们可以设置一个 miter 阈值,超过之后就使用 bevel 接头,对应 SVG 中的 stroke-miterlimit 属性。

相比 miter,在三角化时需要增加一个三角形,对于接头处的每一个顶点,内侧仍然使用 miter 的一个,而外侧需要生成两个:

017cc23da7106170d35070022f51e0c6.png
bevel 接头

除了 miter 和 bevel 接头,还可以使用圆角,例如 deck.gl 的 PathLayer 开启 rounded 选项之后:

fc6861a1ec8dc5ee344ccaba3a137db1.png
圆角接头

对于圆角接头,Mapbox 的做法是在 CPU 预处理时添加大量额外顶点 。

而 GeoJS 完全在 shader 中实现,代价就是传入 GPU 的顶点数据量增大(当前顶点、前后两个顶点)。

反走样

如果仔细观察我们目前绘制的线段,可以发现边缘处明显的走样现象:

a96d0f141c23142a61a695e4cfa8c2c7.png

早期 mapbox 采用过一种办法:绘制出多边形后,在边缘使用 gl.LINES 再进行描边(线宽为2),利用平台对于直线的 MSAA。但是由于不允许设置非 1 的线宽,不得不移除了这种方法。相关 ISSUE & PR 。

如果不依靠基于后处理的几何反走样方式 FXAA/MLAA ,我们有以下几种方式可以尝试。

增加顶点

最直接的办法是增加三角化的三角形数目,例如「Mapbox - Drawing Antialiased Lines with OpenGL」 中就介绍了这种方法,在两侧各增加一对三角形用于渐变:

a4cac507f49abe735ccea7af0ccd76ec.png

但是这对于渲染性能显然是有较大影响的(顶点数 x2,三角形 x3)。

对单位法向量插值

如果能在 fs 中获取到当前 fragment 到原始线段的距离,就可以对边缘进行模糊。

很自然想到之前利用重心坐标实现 wireframe 。但是这种方法有一个明显的缺陷,在 mapbox 的一个 ISSUE「Antialias fills without gl.LINES」 中有清晰的展示,即不同于 wireframe,我们只想处理三角形的某一条边而非三边,并且即使我们找到了这条边,也还会缺少一块:

a3717df483c894f939e5c0a3b40f5f23.png

还记得在vs中我们计算出的normalize之后的法线方向吗?利用varying插值,在fs中获取到单位法向量的插值结果,显然插值后就不是单位向量了(如下图),但我们可以利用向量长度作为模糊因子。

3a370bf0de47a4a9270e410da3159862.png
对单位法向量进行插值
varying vec2 v_normal;

float blur = 1. - smoothstep(0.98, 1., length(v_normal));
gl_FragColor = v_color;
gl_FragColor.a *= blur;

效果如下,可见边缘的走样现象缓解了很多:

5eabbbab7556201c47e4c0e0c0d96e35.png
反走样效果前后对比

Prefiltered

除此之外,还有基于查找表的预过滤方式。基本思路是首先计算出拉伸后的两个 edge function,离线将卷积(box、Gaussian)结果存储在纹理中,在运行时将当前 fragment 位置带入 edge function 得到距离原始线段的长度,再查表得到卷积结果:

「GPU Gems2 - Fast Prefiltered Lines」

「Fast Antialiasing Using Prefiltered Lines on Graphics Hardware」

「Prefiltered Antialiased Lines Using Half-Plane Distance Functions」

解决了走样问题,我们再来看几个常见问题。

在 GPU 中进行墨卡托投影

如果投影变换是在 GPU 而非 CPU 中进行的,例如 deck.gl ,在使用原始经纬度坐标计算出法线方向后,还需要在 shader 中进行转换,例如:

// u_pixels_per_meter 表示在当前经纬度点,一度对应多少像素
vec3 offset = normalize(vec3(lineNormal, 0.0) * u_pixels_per_degree)
  * thickness / 2.0 * lineMiter, 0.0;

由于和缩放等级相关,在每次相机发生改变时,在 CPU 中需要重新计算并传入 u_pixels_per_degree:

const worldSize = TILE_SIZE * scale; // 当前缩放等级下的像素尺寸
const latCosine = Math.cos(latitude * DEGREES_TO_RADIANS);
const pixelsPerDegreeX = worldSize / 360;
const pixelsPerDegreeY = pixelsPerDegreeX / latCosine;

固定线宽

2D 的线在正交投影下可以保证一致的宽度,但是在透视投影下就无法保证了。在某些需要时刻保持线宽一致的 3D 场景下,例如 Cesium 在地形图中展示滑雪路线 :

1b2fbf645ca1346279f926d7a57b43a9.png

常见的做法是投影到屏幕空间,这就需要将之前在 CPU 中对于法线和 miter 的计算挪到 vs 中进行。

通过除以 w 分量转换到 NDC 坐标系,再乘以宽高比就得到了屏幕空间坐标:

vec2 project_to_screenspace(vec4 position, float aspect) {
	return position.xy / position.w * aspect;
}

由于 WebGL 不支持 Geometry Shader,因此除了当前顶点位置,还需要将前后顶点的位置一并传入顶点数据:

attribute vec3 position;
attribute float direction; 
attribute vec3 next;
attribute vec3 previous;

// currentPos、prevPos、nextPos 已经通过 mvp 矩阵投影到裁剪空间

// 投影到屏幕空间
vec2 currentP = project_to_screenspace(currentPos, aspect);
vec2 prevP = project_to_screenspace(prevPos, aspect);
vec2 nextP = project_to_screenspace(nextPos, aspect);

// 计算切线和 miter
vec2 dir1 = normalize(currentP - prevP);
vec2 dir2 = normalize(nextP - currentP);
vec2 tangent = normalize(dir1 + dir2);
vec2 perp = vec2(-dir1.y, dir1.x);
vec2 miter = vec2(-tangent.y, tangent.x);

// 投影到 miter 方向
len = thickness / dot(miter, perp);
vec2 normal = vec2(-dir.y, dir.x);
normal *= len/2.0;
normal.x /= aspect;

// 得到最终偏移量
vec4 offset = vec4(normal * orientation, 0.0, 1.0);

基于 Three.js 的实现:https://github.com/spite/THREE.MeshLine 也采用了类似的做法。Codrop 上有一篇使用它实现各种动画效果的教程 ,感兴趣的也可以阅读下。

虚线

「Shader-Based Antialiased, Dashed, Stroked Polylines」 中使用的方法较为复杂。

这里我们采用一种较为简单的实现,同样还是利用 varying 插值,在 CPU 中对顶点数据进行预处理,对于每个顶点计算总顶点数的占比。但是缺点也很明显,虚线并不是按长度等分的。如果想做到按长度等分,就需要计算每段长度和总长度的占比,相应的会加重预处理顶点数据的负担:

varying float v_counters; // 占总顶点数比例

uniform float u_dash_offset; // 控制起始点,SVG stroke 动画中常见
uniform float u_dash_array; // 控制虚线疏密
uniform float u_dash_ratio; // 控制每小段可见比例

gl_FragColor.a *= ceil(mod(v_counters + u_dash_offset, u_dash_array)
  - (u_dash_array * u_dash_ratio));

效果如下:

2bb3c12f7800a3f2c8086827f7ed2402.gif
虚线效果

总结

绘制直线并不是一件简单的事,尤其是考虑到绘制效果和性能。我们以上的讨论也只是集中在 lineJoin 连结方式上,线的很多其他属性例如端点处的样式 lineCap 并没有涉及。另外除了直线,还可以使用弧线连接两点,例如 deck.gl 的 ArcLayer 。

值得一提的是 GeoJS 对于直线的各种属性支持做的很好,除了 lineJoin(miter | bevel | round)、lineCap(butt | square | round),包括 miter 的阈值、反走样(模糊半径)都可以进行配置。而且从内部实现来看,完全在 shader 中进行,不依赖 CPU 对于顶点数据的预处理,还是很值得进一步研究的。

9cfc4053c67aaee2aa45ca9263929c52.png
GeoJS 中对于直线的配置项

参考资料

基础算法:

「Drawing Lines is Hard」

「Smooth thick lines using geometry shader」

「Journal of Computer Graphics Techniques - Shader-Based Antialiased, Dashed, Stroked Polylines」

反走样相关:

「GPU Gems2 - Fast Prefiltered Lines」

「Fast Antialiasing Using Prefiltered Lines on Graphics Hardware」

「Prefiltered Antialiased Lines Using Half-Plane Distance Functions」

「Mapbox - Drawing Antialiased Lines with OpenGL」

一些成熟 3D 引擎的实现:

「Three.js MeshLine」

「Cesium - Robust Polyline Rendering with WebGL」

「GeoJS - Drawing Lines in GeoJS」

「GeoJS 、Mapbox、Leaflet 效果对比」

「Efficient WebGL stroking」

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值