WebGL 中绘制直线

绘制直线常用的方案

  • 直接使用原生 gl.LINES 的问题

  • 使用沿法向拉伸后三角化的方法绘制直线

  • 接头样式、反走样等常见优化手段

  • Cesium、Mapbox、GeoJS 等成熟引擎的实现

gl.LINES 存在的问题

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

  1. 线宽无法设置

    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.

  2. 无法定义相邻线段间的连接形状 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 。

最后更新于