绘制直线常用的方案
Cesium、Mapbox、GeoJS 等成熟引擎的实现
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
因此我们得考虑将线段转换成其他几何图形进行绘制。
沿法向拉伸
连接接头
复制 // 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);
而 GeoJS 完全在 shader 中实现,代价就是传入 GPU 的顶点数据量增大(当前顶点、前后两个顶点)。
反走样
如果不依靠基于后处理的几何反走样方式 FXAA/MLAA ,我们有以下几种方式可以尝试。
增加顶点
对单位法向量插值
如果能在 fs 中获取到当前 fragment 到原始线段的距离,就可以对边缘进行模糊。
复制 varying vec2 v_normal;
float blur = 1. - smoothstep(0.98, 1., length(v_normal));
gl_FragColor = v_color;
gl_FragColor.a \*= blur;
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;
固定线宽
通过除以 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));
总结
绘制直线并不是一件简单的事,尤其是考虑到绘制效果和性能。我们以上的讨论也只是集中在 lineJoin 连结方式上,线的很多其他属性例如端点处的样式 lineCap 并没有涉及。另外除了直线,还可以使用弧线连接两点,例如 deck.gl 的 ArcLayer 。