使用 WebGL 的动画化线

尝试一下在线预览

重要笔记:

  • 此示例显示实验功能,请在产品中使用之前仔细阅读文档。
  • 此示例是为熟悉 WebGL 和硬件加速渲染的专家开发人员编写的。

此示例演示如何为折线图形实现动画轨迹(或流)效果。此描述假定您熟悉 WebGL 和自定义 WebGL 层视图。它类似于自定义 WebGL 图层视图示例,将点三角化为四边形。此示例改为使用折线三角剖分

updatePositions() 方法已修改为将折线图形的几何图形转换为三角形网格。特殊的 per-vertex 属性被计算并存储在 GPU 上,然后与 per-frame 统一值结合使用,以实现可以同时支持数千条轨迹的高效动画系统。

在此示例中,我们使用自定义代码细分折线;GeoScene API 4.14 版引入了通用的细分例程,开发人员可以使用它来细分任意几何图形;有关更多详细信息,请参阅 SDK 曲面细分示例

创建网格

WebGL 绘图调用对几何图元进行操作。虽然在 WebGL 中支持 gl.LINES 原语,但这不是很灵活,不能用于实现高级效果,例如本示例中展示的效果。最灵活的图元是索引三角形列表,可以使用 gl.drawElements(gl.TRIANGLES, ...) 进行渲染。示例应用程序使用一个这样的调用来渲染视图中的所有折线。 为了提供光栅化,我们需要设置适当的顶点属性和索引缓冲区。

三角线

折线可以用三角形表示。一种简单的三角剖分方案是将原始折线的每个顶点以相反方向拉伸到两个 GPU 顶点的方案。 然后使用两个三角形连接四个 GPU 顶点的组,总共六个索引。

polyline-extrusion

下图举例说明了挤压的几何形状。设 α 为转弯角度,w 为屏幕上折线的宽度,以像素为单位。挤压方向与线段的每个法线形成 α / 2 的角度;挤出量为 w / (2 cos(α / 2))

polyline-geometry

我们使用向量代数来研究顶点拉伸。让我们考虑一条折线的三个连续顶点 ab 和 c ,并假设我们要计算驱动点 b 挤出的偏移向量。让我们分别考虑段 ab 和 bc。我们计算从 a 到 b 的向量 Δ1 = b - a;让 (dx, dy)  = Δ1 / ||Δ1|| 是表示线段 ab方向的归一化向量;那么 n1 = (-dy, dx) 是该段的法线。对于段 bc,法线  n2 可以以相同的方式计算。 然后可以将偏移向量的方向计算为 n1 和 n2 的归一化平均值,即  offsetDir = (n1 + n2) / ||n1 + n2||

polyline-vectors

我们已经描述了拉伸的方向,但没有描述它的数量;直觉上,急转弯应该会导致比浅转弯更大的拉伸; 此外,如果我们想让多段线看起来更粗,我们应该拉伸更多。 让我们将折线的宽度标准化为 2 ,使其边缘刚好接触到法线向量的尖端。考虑具有 n1(或 n2)和 offset 作为边的直角三角形。很容易看出 offset cos(α / 2) 必须等于 1,因为法线是单位长度;由此我们可以得出结论,偏移向量的长度正好是 1 / cos(α / 2)。对于宽度为 w 的折线,我们必须将偏移量缩放 w / 2倍, 这导致了我们在本节开头介绍的 w / (2 cos(α / 2)) 因子。 

polyline-trigo

每个折线图形被处理一次,并且拉伸信息被捕获在存储在顶点缓冲区中的属性中。使用索引缓冲区将挤出的顶点连接成三角形。这些生成的缓冲区驱动光栅化过程,不需要在每一帧重新生成。我们仅在视图变得静止时才重新生成它们,以解决 GPU 上浮点数的有限精度。 有关此技术的讨论,请参阅自定义 WebGL 层视图

顶点格式

原始折线的每个顶点产生两个 GPU 顶点。它们具有 position 属性,与它们起源的顶点相同,并且使用相同的空间参考单位。GPU 顶点也有一个 offset 属性,它是一个指向被拉伸的 GPU 顶点的单位向量。 一对中的两个偏移矢量彼此相反。请注意,我们存储 _normalized_offset 向量。这些是使折线看起来宽度等于2 像素的偏移量。顶点着色器负责缩放偏移属性,使多段线渲染得更粗。

polyline-attributes

要将纹理应用于线条,我们需要在其上构建 UV 空间。这需要两个新属性:

  • distance - 定义沿 线增加的坐标。它以空间参考单位测量,并在第一个顶点上从 0 开始,并随着每个折线段增加。
  • side - 根据 GPU 顶点所属的拉伸折线的边缘,将其设置为 +1-1

我们希望每条线都有不同的颜色。我们可以使用制服来存储颜色,但这会阻止我们使用单个调用绘制所有线条。因此,我们将颜色编码为 32 位 RGBA 属性。

polyline-vertex

三角测量算法

折线三角测量算法在 updatePositions() 方法中实现,并假设存储在图层上的所有图形都具有折线几何形状。

我们首先计算所需的顶点和索引的数量,我们通过迭代所有图形来完成。为简单起见,我们只对每个图形的第一条路径进行三角剖分。一条有 N 个顶点的折线有 N - 1 条线段。对于每个顶点,我们需要以相反方向挤出的 2GPU 顶点。对于每个段,我们需要两个三角形(即 6 个索引)。

          
1
2
3
4
5
6
7
8
9
10
let vtxCount = 0;
let idxCount = 0;

for (let i = 0; i < graphics.items.length; ++i) {
  const graphic = graphics.items[i];
  const path = graphic.geometry.paths[0];

  vtxCount += path.length * 2;
  idxCount += (path.length - 1) * 6;
}

我们为 vtxCount 顶点分配了一个有足够空间的 ArrayBuffer。每个顶点有 6 个浮点值和 4 个字节的颜色,所以我们总共需要 7 * vtxCount * 4 个字节。我们为这个内存区域创建了两个视图;一个用于写入浮点值,另一个用于写入颜色。我们还分配索引内存并根据上下文创建一个无符号短视图。

    
1
2
3
4
const vertexData = new ArrayBuffer(7 * vtxCount * 4);
const floatData = new Float32Array(vertexData);
const colorData = new Uint8Array(vertexData);
const indexData = new Uint16Array(idxCount);

然后我们开始计算和写入顶点属性。写入顶点和索引内存发生在两个游标指向的位置,从零开始。

  
1
2
let vtxCursor = 0;
let idxCursor = 0;

然后我们迭代所有图形,只考虑每个图形的第一条路径。对于每条路径,我们需要运行三角测量算法。我们首先用一个空的三角剖分状态 {} 初始化一个变量 s;随着形成路径的各个点被处理,这种状态将发生变化。 每次我们处理完一个图形,我们在处理下一个图形之前将状态 s 重置为 {}

       
1
2
3
4
5
6
7
let s = {};

for (let j = 0; j < path.length; ++j) {
  const p = path[j];

  // ...here we process p...
}

为了处理当前顶点 p ,我们首先检查状态 s。如果这不是这条路径的第一次迭代,我们将已经有了 s.current 顶点并使用它来计算 deltaas p - s.current。然后我们将线段的长度计算为 delta 的范数(归一化的 delta 是线段的方向)。通过将该方向旋转 90°,我们可以获得线段的法线。

    
1
2
3
4
s.delta = [p[0] - s.current[0], p[1] - s.current[1]];
const deltaLength = Math.sqrt(s.delta[0] * s.delta[0] + s.delta[1] * s.delta[1]);
s.direction = [s.delta[0] / deltaLength, s.delta[1] / deltaLength];
const normal = [-s.direction[1], s.direction[0]];

对于折线的第一个和最后一个顶点,线段法线是确定拉伸的偏移向量。对于所有其他中间顶点,共享一个顶点的两个线段的法线必须按法线之间夹角一半的余弦的倒数进行平均、归一化和缩放。这可以计算为段法线和归一化(但仍然未缩放)偏移量之间的点积。先前的线段法线从三角剖分状态中检索为 s.normal

       
1
2
3
4
5
6
7
s.offset = [s.normal[0] + normal[0], s.normal[1] + normal[1]];
const offsetLength = Math.sqrt(s.offset[0] * s.offset[0] + s.offset[1] * s.offset[1]);
s.offset[0] /= offsetLength;
s.offset[1] /= offsetLength;
const d = s.normal[0] * s.offset[0] + s.normal[1] * s.offset[1];
s.offset[0] /= d;
s.offset[1] /= d;

然后将计算值写入属性缓冲区。我们使用浮点视图来写位置、偏移量、距离和边,我们使用无符号字节视图来写颜色。

                     
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
floatData[vtxCursor * 7 + 0] = s.current[0] - this.centerAtLastUpdate[0];
floatData[vtxCursor * 7 + 1] = s.current[1] - this.centerAtLastUpdate[1];
floatData[vtxCursor * 7 + 2] = s.offset[0];
floatData[vtxCursor * 7 + 3] = s.offset[1];
floatData[vtxCursor * 7 + 4] = s.distance;
floatData[vtxCursor * 7 + 5] = +1;
colorData[4 * (vtxCursor * 7 + 6) + 0] = color[0];
colorData[4 * (vtxCursor * 7 + 6) + 1] = color[1];
colorData[4 * (vtxCursor * 7 + 6) + 2] = color[2];
colorData[4 * (vtxCursor * 7 + 6) + 3] = 255;
floatData[vtxCursor * 7 + 7] = s.current[0] - this.centerAtLastUpdate[0];
floatData[vtxCursor * 7 + 8] = s.current[1] - this.centerAtLastUpdate[1];
floatData[vtxCursor * 7 + 9] = -s.offset[0];
floatData[vtxCursor * 7 + 10] = -s.offset[1];
floatData[vtxCursor * 7 + 11] = s.distance;
floatData[vtxCursor * 7 + 12] = -1;
colorData[4 * (vtxCursor * 7 + 13) + 0] = color[0];
colorData[4 * (vtxCursor * 7 + 13) + 1] = color[1];
colorData[4 * (vtxCursor * 7 + 13) + 2] = color[2];
colorData[4 * (vtxCursor * 7 + 13) + 3] = 255;
vtxCursor += 2;

在我们发射了至少四个顶点之后,我们就可以开始发射索引了。在每次迭代中,我们发出六个索引 - 两个三角形连接刚刚计算的两个拉伸 GPU 顶点与上一次迭代中计算的顶点。

       
1
2
3
4
5
6
7
indexData[idxCursor + 0] = vtxCursor - 4;
indexData[idxCursor + 1] = vtxCursor - 3;
indexData[idxCursor + 2] = vtxCursor - 2;
indexData[idxCursor + 3] = vtxCursor - 3;
indexData[idxCursor + 4] = vtxCursor - 1;
indexData[idxCursor + 5] = vtxCursor - 2;
idxCursor += 6;

在继续下一次迭代之前,我们需要创建最新点、最新计算的法线、当前点,并将距离增加本次迭代处理的段的长度。

   
1
2
3
s.normal = normal;
s.distance += deltaLength;
s.current = p;

有两种特殊情况我们只是简单地提到过:

  • 第一个顶点的偏移量等于第一个计算的法线(即 const normal = [-s.direction[1], s.direction[0]]),因为没有以前的法线对其进行平均。
  • 相反,最后一个顶点的偏移量等于在处理最后一个段时计算的最后一个法线,并由状态(即 s.normal)恢复。

为了帮助您理解三角剖分逻辑,我们准备了一个 pen,在一个简单的 Canvas2D 应用程序的上下文中展示了算法的内部工作。

使用着色器创建轨迹效果

在本节中,我们将简要讨论实现彩色轨迹效果的顶点和片段着色器。

顶点着色器

原始顶点通过变换矩阵进行变换,变换矩阵由地图中心、比例和旋转确定。然后通过添加由拉伸矩阵缩放和旋转的偏移向量来拉伸顶点,该矩阵包括半线宽因子但对地图比例不敏感,因此随着视图的放大,线不会变大。最后,拉伸向量由视口大小决定的显示矩阵变换。距离、边和颜色属性不变地传递给片段着色器。

插值器将使片段 (v_distance, v_side) 对平滑变化,以使中心线上的 v_side 等于 0,而折线一端的 v_distance0,另一端等于以地图为单位的折线长度。

                     
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
uniform mat3 u_transform;
uniform mat3 u_extrude;
uniform mat3 u_display;

attribute vec2 a_position;
attribute vec2 a_offset;
attribute float a_distance;
attribute float a_side;
attribute vec4 a_color;

varying float v_distance;
varying float v_side;
varying vec4 v_color;

void main(void) {
  gl_Position.xy = (u_display * (u_transform * vec3(a_position, 1.0) + u_extrude * vec3(a_offset, 0.0))).xy;
  gl_Position.zw = vec2(0.0, 1.0);
  v_distance = a_distance;
  v_side = a_side;
  v_color = a_color;
}

片段着色器

片段着色器分别根据片段沿线和 _across_the 线的位置计算两个不透明度因子 a1a2。如上一段所述,此信息由插值 (v_distance, v_side) 对捕获。因子 a1 取决于当前时间和与当前片段关联的距离,而 a2 使得线在中心线附近更不透明,在边缘附近更透明。我们使用了一个 mod 操作,以便轨迹沿每条线在空间上重复,并且每条线永远不会用完要显示的轨迹。

                 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
uniform float u_current_time;

varying float v_distance;
varying float v_side;
varying vec4 v_color;

const float TRAIL_SPEED = 50.0;
const float TRAIL_LENGTH = 300.0;
const float TRAIL_CYCLE = 1000.0;

void main(void) {
  float d = mod(v_distance - u_current_time * TRAIL_SPEED, TRAIL_CYCLE);
  float a1 = d < TRAIL_LENGTH ? mix(0.0, 1.0, d / TRAIL_LENGTH) : 0.0;
  float a2 = exp(-abs(v_side) * 3.0);
  float a = a1 * a2;
  gl_FragColor = v_color * a;
}

其他可视化示例和资源

Your browser is no longer supported. Please upgrade your browser for the best experience. See our browser deprecation post for more details.