旷视研究院 · 2022年02月25日

技术的真相 | 使用 OpenGL 实现线型动画的教程来啦!

image.png

在开发 UI 的时候,难免会遇到设计同学的奇思妙想超出了你的想象范围的情况。比如说,设计同学可能会让你画一个像下面这样不停运动的五角星动画:

1.gif

这样的线型动画也不只是为了好玩或者好看,在许多实用的领域比如监控图表或者 AI 可视化的过程中也可以发挥各种作用。

使用程序绘制这样的图形,通常会选择直接调用原生的绘制图像 API,比如 OpenCV 的 Drawing Functions,或者 Windows 下的 GDI/GDI+,或者 Web 下面的 Canvas 的绘制接口,或者 Android/iOS 相应的接口等等。

但对于前面图中这样的需求来说,需要在每一帧中计算需要绘制的线是哪些,从哪里开始到哪里结束,根据直线和曲线选用不同的 API,当线有一定宽度的时候,根据采用的 API 不同往往还会遇到转角的地方效果不尽如人意之类的情况,要实现图中的渐隐效果也会很困难。

写定代码之后,要调整曲线形状也很不容易(比如在五角星的尖上面增加一个小圆角),很可能需要推翻重写。实际运行起来时,还可能会发现因为计算过程过于复杂而产生性能问题。更不用说对于 Windows/Qt/Web/Android/iOS 等多个平台,代码是完全不通用的,极大增加了开发成本。

另一个方案是直接使用更为底层的绘图 API 来实现这样的效果,考虑到跨平台的需要,目前 OpenGL 仍然是一个比较稳妥的方案,尤其是许多设备上的浏览器只支持 WebGL 1.0,如果能在 WebGL 1.0(大约相当于 OpenGL ES 2.0)上仅使用 WebGL API 实现这种线型动画,那么绝大多数平台都可以使用相同的方案进行绘制,代码逻辑很容易迁移。本文介绍的就是这样一种方案。  

OpenGL 基础知识

OpenGL 接口的不同版本有一些很不同的特性,大体上按照固定管线、可编程管线、进一步扩展的可编程管线进行演进,这里按照 OpenGL ES 2.0 版本进行介绍,涉及到的特性基本上可以在目前主流的所有 OpenGL 平台(包括WebGL)上得到支持。

在 OpenGL ES 2.0 版本的可编程管线流程大致可以描述为:

顶点(vertex)数据 → 顶点着色器(vertex shader) → 顶点后处理 → 光栅化(rasterization) → 片段着色器(fragment shader) → 采样后处理

其中,可编程的部分主要指顶点着色器(vertex shader)和片段着色器(fragment shader)两部分,其他部分的功能相对固定。两种着色器使用被称为 GLSL 的编程语言编写,风格和 C 语言类似,主要控制输入数据经过怎样的运算之后得到输出数据,例如,一个简单的 OpenGL ES 2.0(WebGL 1.0)可用的顶点着色器可以如下编写:

precision highp float;
precision highp int;

attribute vec2 a_position;
uniform mat4 u_transform;
varying vec2 v_position;

void main(){
  gl_Position = u_transform * vec4(a_position, 0.0, 1.0);
  v_position = a_position;
}

它有一个二维向量类型的顶点属性(attribute)输入 a_position,同时有一个常量(uniform)输入 u_transform,类型是一个 4x4 的矩阵。它输出了 OpenGL 要求的顶点坐标 gl_Position,同时定义了一个输出给片段着色器的结果 v_position。向量被补充为四维齐次坐标向量之后,使用矩阵进行变换,得到的结果作为顶点着色器的输出(用 gl_开头的特定变量表示),同时原始的 a_position 被直接传递给了 v_position,方便在片段着色器里进一步处理。

着色器会针对每个要处理的单元运行一次,顶点着色器是对每个顶点执行一次,片段着色器则是对每个采样(通常是每个像素)执行一次,attribute(或者片段着色器中的 varying)变量会在每次执行中得到不同的输入,而 uniform 则是在同一次绘制中设置的相同的值,例如上面的例子中,每个顶点执行时会有不同的 a_position 的值,但 u_transform 则都是同一个矩阵,这样所有的顶点都会完成相同的线性变换,就实现了物体整体旋转缩放之类的效果。

片段着色器的编写方式与顶点着色器类似,只是输入输出有所差异,片段着色器的输入是顶点光栅化之后得到的像素点对应的属性,输出则主要是对应像素点的颜色。这个颜色最后会被绘制到屏幕或者内部缓存中的相应位置上,最终就实现了使用 OpenGL 绘制图形的目标。

注意不同版本的 GLSL 的语法有很大差异,例如 OpenGL 桌面版本通常没有前面的 precision 声明,而且通常需要指定编译版本,如果针对特定平台编程,需要参考相应的文档,本文出于篇幅和突出重点的目的不再详细介绍 GLSL 的编写方式。

回到前面的管线流程上,我们简单介绍一下整个流程:

顶点数据定义

OpenGL 通过自己定义的数据结构将顶点对应的数据传输到 GPU,并且指定每组数据分别对应到片段着色器的哪个输入变量。

每个顶点可以有多个输入,这些输入代表什么含义由调用者自由决定,没有固定的规则,不过通常会让其中一组描述顶点的空间坐标,其他则描述某些和顶点相关的属性。例如本文中,每个顶点就会输入空间坐标、法向量、局部坐标三组顶点属性,这三组属性会变成顶点着色器的三个输入。

顶点着色器

顶点着色器的任务通常是变换并且输出顶点的坐标(gl_Position),并且输出其他属性给片段着色器。gl_Position 必须是一个四维的齐次坐标(x, y, z, w),实际使用时,它代表的通常是(x/w, y/w, z/w)的三维坐标,这是为了绘制 3D 图形时方便处理透视,对于 2D 的绘制来说,通常只需要指定一个固定的 z(例如0.0),并且将 w 指定为 1.0 即可。

w 坐标还会影响光栅化时属性插值的结果,让显示的结果和 3D 透视时一致。对于前三个坐标来说,(x/w, y/w)是个直角坐标系坐标,它决定了顶点在屏幕上显示的位置,(-1, -1)表示视口(viewport)的左下角,(1, 1)表示视口的右上角,(0, 0)是视口正中心。z/w 在开启了深度测试的情况下可以用来判断并裁剪掉被遮挡的部分,在 3D 绘制的时候常用。

顶点后处理

有几个简单的固定操作,主要是将顶点着色器输出的顶点组合成需要绘制的元素(primitives)例如三角形,然后根据设置去掉一些不需要渲染的部分(比如超出绘制范围或者三角形朝向与设置方向相反的),准备进行光栅化。

光栅化

光栅化是 OpenGL 渲染过程中相当重要的步骤,不过它基本上是个固定过程,没有太多可以定制的部分。它的功能是根据输入的三角形坐标,计算出有哪些像素被包括在这个三角形内,并且为每个像素计算出顶点属性的插值结果,它可以用下面的图示来表示(By Martin Kraus - Own work, CC BY-SA 3.0, https://commons.wikimedia.org/w/index.php?curid=15369626)

image.png

经过光栅化之后,顶点着色器输出的一系列顶点构成的三角形会被转化为需要渲染的像素位置。对于每个位置,光栅化过程会计算出这个点的插值系数,可以理解为三个和为 1 的数,如果这个点离某个顶点越近,相应的系数就越接近 1,否则越接近于 0,顶点着色器输出的属性会通过这三个系数进行混合,在整个三角形中呈现出一个渐变的效果。通常来说,一个三角形内属性的变化是近似线性的,插值的结果一般都会和真实结果比较接近。

片段着色器

片段着色器对每个采样(多数情况下每个像素点对应一个采样)执行一次,输入是经过插值的顶点属性,和顶点着色器输出的内容相对应,我们通常将它理解为这个像素点上的属性。

例如本文中,就会在顶点着色器中将局部坐标作为一个顶点属性输出,这个局部坐标经过光栅化过程插值之后交给片段着色器,用来代替当前像素点的局部坐标,这个方法并不完全精确,但对一般的图形来说精度是足够的。

片段着色器除了像顶点着色器一样可以使用属性和外部传入的常量(uniform)以外,还可以使用贴图(texture),它可以根据输入坐标查询一张输入图像中相应位置的颜色,可以用来实现多种多样的效果,本文中并没有涉及这种技术。

片段着色器主要输出 RGBA 四分量的颜色值,作为在这个像素点上绘制的结果。RGB 分别表示红、绿、蓝分量,A(Alpha)通道可以表示透明度,用来实现透明或者半透明叠加的效果。

采样后处理

对于片段着色器的输出,OpenGL 会进行一些最终的处理并将它合并到帧缓冲(framebuffer)中,主要包括裁剪(scissor)、模板(stencil)、深度测试(depth test)、颜色混合(blending)等。

颜色混合可以使用输出颜色中的 Alpha 通道进行透明度混合的操作,本文中就是使用这个方法让绘制出的一部分像素变成透明,从而实现只绘制出需要部分的效果。

整体思路

首先我们来总结一下本文中的方案需要解决的需求的大致特点:

  1. 整体上来说,要画的是一条线
  2. 线沿着基本固定的轨迹运动,我们希望能通过程序控制绘制的起始和终止点,当然最好也包括粗细
  3. 线可能会有一些淡入淡出或者颜色渐变之类的特效
  4. 线的开头或结尾可能有一些特殊符号,例如空心圆圈、实心圆圈等

从方案上来说,首先我们可以将线都统一为折线:如果遇到圆弧、曲线这样的情况,我们通常可以用很多个点的折线来近似。对于一部分绘图库来说,每帧绘制过多的线段可能带来性能问题,但 OpenGL 处理三角形的能力是非常出色的,几千个三角形的量级在任何主流设备上都可以说不在话下,而一般数百个点的折线就足以处理绝大多数光滑曲线了,通过提前将顶点存入显存,每帧绘制性能并不会受到影响。

作为一个实例,我们考虑绘制下面的曲线,它由两头的水平线和中间的正弦曲线构成:

image.png

我们可以运用高中函数相关的知识,以固定的 x 坐标间隔求出相应的 y 坐标,然后组成原始的点列,用来做后续步骤的绘制。

绘制完美折线

熟悉 OpenGL 的同学可能会知道,OpenGL 虽然支持画线,但在绘制线宽和线条抗锯齿上经常会遇到兼容性的问题,不同设备绘制出的效果经常会有出入,而且在线比较粗的时候,我们通常会希望对转角之类的部分有更精细的控制,所以更好的做法是使用三角形(GL_TRIANGLE_STRIP)来绘制线。对于一个简单的线段,我们直接用一个矩形来代替即可,矩形的长边和原始线段平行,宽则为需要的线宽度,原始线段在矩形中心即可。
image.png

对于折线来说,可不可以每一段都用矩形代替呢?很容易能想到,在转角的地方,这样的方法可能会导致转角出现不太完美的效果,可能有接不上的地方,也可能有重叠的部分,或者额外突出的角之类:

image.png

为了让折线接到一起,我们仍然需要让每一段线段都由一个四边形组成,下一段折线的前两个点必须正好就是上一段线段的最后两个点,这样的连续的三角形序列可以用 OpenGL 中的 Triangle Strip 表示,每个顶点只需要传递一次,数据效率很高。我们注意到,要让折线接起来,方法应该是求前后两段线段在折线两侧各自的平行线的交点:

image.png

image.png

image.png

另一个方向的点可以用相同的方法计算,可以发现对应的向量正好是这个向量取反,因此每个点只需要计算一次合成法向量,然后按照相反的方向分成两个点即可。

知道折线坐标之后,我们可以提前对折线中的每一点计算出合成法向量。顶点的实际坐标可以使用 OpenGL 中的顶点着色器(vertex shader)计算,只要为每个顶点传入原始坐标和合成法向量,并传入需要绘制的宽度(的一半),就可以计算出需要的顶点的坐标,使用这些顶点坐标构成连续的 Triangle Strip,就可以绘制出完美的折线。

使用这种方法,我们就可以将前面的曲线绘制成有宽度的线,效果在各个设备上都是一致的:

image.png

注意到水平线转正弦的时候,有一个锐利的转角,这就是前面的算法的效果。同时也可以看到,正弦峰和谷的地方本来应该是光滑的,但是经过展宽之后,外侧仍然比较光滑,而内侧显得有个尖点,这也是正常的现象,数学上来说这和曲线的曲率半径有关,可以想象如果是一个小圆,拓宽之后会变成一个圆环,内径会变小;如果拓宽超过了小圆的半径,则内径就完全消失了,上面的看上去尖的地方就是类似的现象,设计曲线的时候可以尽量规避,只要曲线的曲率半径不超过线宽的一半就没有问题。

注意如果曲线是封闭的,第一个点和最后一个点的合成法向量计算是,应该循环地考虑前一个和后一个点,这样封闭曲线才能完美接起来。

局部坐标

仅仅是画出静止的线不能起到我们想要的动画的作用。为了实现动画,我们的思路是每次绘制的时候,通过片段渲染器(fragment shader)控制实际显示的部分,这样通过不同的参数就可以控制实际显示的线的部分,从而方便地实现动画效果。为了控制显示的线的范围,在通常的屏幕坐标系统以外,我们需要引入一套称为曲线局部坐标的系统:

image.png

如图,我们构建一个跟随折线旋转的坐标系,它的 x 轴永远指向当前折线前进的方向,而 y 轴则指向和当前前进方向垂直的方向,这样折线拓宽之后的每一个点都有一个新的二维坐标,x 坐标代表从折线起点走到当前位置之后经过的距离,这个距离可以在预处理阶段对折线上每一点提前计算出来;y 坐标则代表当前点到中线的偏差,我们规定以前进方向为基准,左侧为负,右侧为正。这样就可以得到上面图中的坐标。

由于 OpenGL 中 attribute 都是设置在顶点上的,这里我们简单认为 y 轴方向就是前面计算的合成法线方向,这样本地坐标和轴方向的关系就一致了,但因为这个简化,如果折线有很明显的转角,中途 x 轴和 y 轴可能会不平行,一般来说这是个小问题。

局部坐标的 x 分量需要针对整条曲线提前计算出来,这可以在前面计算法线的过程中同时完成,只要不停累加每一段线段的长度,就可以知道从第一个顶点出来,走到某个顶点之后累积经过的路程,这就是这一点的局部 x 坐标。

局部 y 坐标则可以和前面的顶点坐标一样,使用顶点着色器计算出来,简单一点的方法是用 -1 或 1 作为输入,曲线左侧的点输入 -1,右侧的点输入 1。实际的 y 坐标只需要用输入的 y 坐标乘以曲线宽度的一半就可以得到了。

结合前面的合成法线的部分,我们可以总结一下顶点着色器的输入输出:

image.png

片段着色器渲染

有了这个局部坐标之后,我们就可以很容易处理动画的问题了:OpenGL 会在光栅化过程中自动为每个像素插值,求出这个像素对应的局部坐标。要调整显示线的范围,只需要根据局部坐标,筛选局部坐标 x 值在某个区间中的像素,就可以正确显示折线的一个连续部分。如果折线是首尾相连的,绕过连接点的时候,需要画开头和结尾的两个区间,可以在片段着色器(fragment shader)中特殊处理一下。

我们可以把曲线的局部坐标用不同的颜色画出来:

image.png

上图中,局部 x 坐标用颜色中的 R(红色)分量表示,局部 y 坐标用 G(绿色)分量表示。可以看到,随着曲线向前推进,颜色逐渐变红,说明 x 坐标逐渐递增;同一位置从左到右逐渐变绿(黑色变成绿色,或者红色变成黄色,注意黄色=红色+绿色),这表示 y 坐标逐渐递增。注意局部 x 坐标和局部 y 坐标都并非一个固定方向,而是随着曲线行进跟着“扭动”的一个坐标系,在正弦上升阶段,y坐标正方向是指向右下的,而下降阶段是指向右上的,永远垂直于前进方向。这个图示可以帮助理解局部坐标的作用。

有时我们会发现画出的曲线边缘有些锯齿,或者我们想绘制一些边缘羽化之类的效果,这只需要根据局部 y 坐标进一步筛选显示的像素并且设置透明度(alpha)通道的过渡段即可,下面的例子是对应的效果:

image.png

抗锯齿

image.png

羽化(边缘模糊)

通过结合曲线法线推导公式,或者在片段着色器中手动多采样,可以在不支持高分辨率也不支持自动多采样的设备上实现更好的抗锯齿效果,读者可以自行尝试,这里不再详细介绍方法。

筛选 x 坐标范围可以实现绘制部分曲线,将这个范围通过 uniform 传入片段着色器(fragment shader),并且每一帧按照时间设置不同的值,就可以实现动画效果,通过设置过渡段可以实现淡入淡出效果:

2.gif

绘制圆圈标记

如果需要在曲线开头或结尾绘制一些小标记,比较直接的方法是先求出这些点的绝对位置,然后在这个位置单独用 OpenGL 绘制对应的图像,这样自然是最灵活效果也最好的,不过在要求不太高的情况下,我们也可以使用前面的本地坐标绘制一些简单的图形。

以绘制圆环为例,通常我们必须知道圆心的真实坐标,才能绘制出圆环。不过我们可以注意到,在坐标系没有剧烈变换的情况下,本地坐标的距离和真实坐标的距离基本上是一致的,这样我们可以用本地坐标来代替真实坐标列出圆的方程:

image.png

这里所有的坐标都直接采用本地坐标,而本地坐标系下的圆心位置很容易知道,

image.png

为了绘制出空心圆环,我们需要将实际绘制的三角形宽度设置得比需要的线宽更大一些,让它能包含整个圆头,然后使用前面说的 y 坐标筛选的方式绘制比较细的线。同时,我们判断当前点是否位于圆环范围内,使用以下的逻辑:当(当前点不在圆环内径范围内且 (当前点在圆环外径范围内 或 当前点在线宽范围内))时,绘制这个点。这样就可以将线和圆环完美连接起来。

利用这个技术,我们在上面的动画的基础上,增加一个小圆圈:

3.gif

注意圆圈到达水平位置的时候会变形,这个是前面的本地坐标方案的局限性,在大转角的时候本地坐标距离和真实距离偏差比较大,在曲线的部分效果很好。这种方法的优势在于通过一次 draw 就可以绘制出曲线和标记,甚至可以通过修改 fragment shader 在曲线上同时绘制多个标记。

更多示例

采用相同的技术,可以绘制出更多炫酷的效果,一起来试试吧!

4.gif

5.gif

首发:旷视研究院
作者:胡波

专栏文章推荐

欢迎关注旷视研究院极术社区专栏,定期更新最新旷视研究院成果
加入旷视:career@megvii.com
推荐阅读
关注数
7696
内容数
164
专注旷视研究院学术论文解读推送,涵盖计算机视觉,文字识别等
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息