manimgl 二阶贝塞尔曲线的上色机制
1. 前言
大三下学期好巧不巧的选了一个数据可视化的课,然后就借此机会学了一点点 OpenGL 的知识。于是就想借着这份兴趣,把没人去研究的 manimgl shader 部分稍微研究一下,也可以作为自己的一点学习经验吧。
虽说选上的这门课很无聊,而且选这门课的人只有 20 多位,可想而知这是有多劝退啊。但从另一方面想,其实也应该算是一个比较好的契机,毕竟之前大二寒假学的 glsl 也只能瞎画画,做不了什么比较出色的作品。同时,我也 fork 了一个 OpenGL 开发环境的仓库,它是搭建在 Windows 端的,而我也一步步踩坑,才成功搭起了 macOS 的开发环境 总之搭环境真累 。仓库指路
在此还要感谢 pdcxs 大佬,搬运了不少图形学的教学视频,也录制了很多 glsl 的教学,引导我们了解了很多例如 sdf, ray marching 之类的概念,也为理解 manimgl 中贝塞尔曲线上色机制做了铺垫。
最终成果在 manimgl 中文文档,有兴趣的读者可以前往阅读。
2. 基本概念
GLSL 中的一些内置函数
mix
mix 函数会对传入的两个值进行插值运算,即有
smoothstep
泛型有
对于浮点类型有
它满足下面的性质
- 当 时,返回值为 0
- 当 时,返回值为 1
- 当 时,返回值为从 0 到 1 的一个平滑插值
特别的,如果 时,满足下面的性质
- 当 时,返回值为 1
- 当 时,返回值为 0
- 当 时,返回值为从 1 到 0 的一个平滑插值
sdf符号距离函数
这是图形上色的一个概念,在空间的一个有限区域上确定一个点到边界的距离,并用符号来定义距离
- 在边界内部的,符号定义为正
- 在边界外部的,符号定义为负
听上去还是云里雾里的,那举个例子吧。假设我们想要画一个圆,我们首先会想到,定义一个函数,返回值为 4 分量的颜色,然后调用这个函数,把得到的颜色直接涂到片段 frag_color 上。
“这样一定很完美吧。”我们可能会这么想。但如果图形很复杂,需要嵌套非常多的 if-else 呢?这样就显得很不优雅了罢。而且这样做,会把先前已经上过色的片段给覆盖掉,于是我们需要寻找更好的方案,来代替这种浅显的方法。
此时,我们回到符号距离函数上来,即用符号来定义距离。我们让圆内的点,返回值都大于 0,圆外的点,返回值都小于 0。此时,函数的返回值就是一个 float 类型。
这时,我们已经成功用符号来区分片段了。那么怎样才能将这种区分体现到着色的片段上呢?这就需要使用 mix 函数了。于是,我们根据刚才得到的 sdf,尝试一下。
展开源码
好像这个效果不尽如人意诶,毕竟这个 sdf 也只是区分了一下符号,我们想要把大于 0 的片段,全部使用 vec3(1.)
的颜色,而小于 0 的片段,全部使用原来的颜色片段。
此时我们请出 smoothstep 函数,让小于 0 的片段结果都为 0,大于 0 的片段都为 1,我们可以得到这样的结果
展开源码
同时我们也发现,由于 smoothstep 的平滑补间效果,图形边缘的锯齿也改善了很多,在此处看得其实不是很清晰。
OpenGL 运行流程
总体流程图
顶点数组
首先,OpenGL 从程序中读取顶点,在程序中就主要以数组的形式给出。
顶点着色器
数组数据传入顶点着色器,对于每一个顶点,这一步的赋值就相当于把颜色、位置、透明度等属性赋值给了一个顶点,接着需要继续向后传递。
图元装配
在图元装配这一步中,程序会给定绘制点、线、或面,有下面的表格。
图元装配种类 | 解释 |
---|---|
GL_POINTS | 点。所有顶点不连接,直接以点的形式显示。 |
GL_LINES | 线。每两个顶点相连接,每个顶点不复用,形成多条线。 |
GL_LINE_STRIP | 线带。每两个顶点相连接,除首、尾顶点外的其他顶点复用,形成不闭合的线带。 |
GL_LINE_LOOP | 线环。每两个顶点相连接,所有顶点可复用,形成闭合的线环。 |
GL_POLYGON | 多边形。所有顶点相连接,形成凸多边形。 |
GL_TRIANGLES | 三角形。每三个顶点相连接,顶点不复用,形成三角形。 |
GL_TRIANGLE_STRIP | 三角形带。每三个顶点相连接,顶点复用,形成三角形带。 |
GL_TRIANGLE_FAN | 三角形扇。以某个顶点作为公用顶点,和其他相邻的两个顶点相连接,组合成多个相邻的三角形,形成三角形扇。 |
GL_QUADS | 四边形。每四个顶点相连接,顶点不复用,形成四边形。 |
GL_QUAD_STRIP | 四边形带。每四个顶点相连接,顶点复用,形成四边形带。 |
几何着色器
几何着色器的作用,就是将输入图元作为初始数据,创建更加复杂的图元。
头部声明
输入类型 | 说明 | 数组大小 |
---|---|---|
points | 绘制GL_POINTS图元时 | 1 |
lines | 绘制GL_LINES或GL_LINE_STRIP时 | 2 |
lines_adjacency | GL_LINES_ADJACENCY或GL_LINE_STRIP_ADJACENCY | 4 |
triangles | GL_TRIANGLES、GL_TRIANGLE_STRIP或GL_TRIANGLE_FAN | 3 |
triangles_adjacency | GL_TRIANGLES_ADJACENCY或GL_TRIANGLE_STRIP_ADJACENCY | 6 |
同时,需要在头部加上输入布局修饰符
输出类型 | 说明 |
---|---|
points | 点 |
line_strip | 线 |
triangle_strip | 三角形 |
在头部加上输出布局修饰符,几何着色器同时希望我们设置一个它最大能够输出的顶点数量(如果你超过了这个值,OpenGL 将不会绘制多出的顶点),这个也可以在 out 关键字的布局修饰符中设置。
图元创建
在 C++ 的 std::cout
中,我们将想要输出的变量插入到流中,最后加上 std::endl
刷新缓冲区,将这些数据输出在控制台上。
对于图元也是可以用这样类似的方法来理解。例如我们就拿上面给到的,输入图元为三角形,输出图元最大顶点数为 5 作为例子。假设有输入输出变量如下:
接下来更改 gl_Position
和 color
,在调用 EmitVertex()
时将该顶点提交到输出图元,其中 gl_Position
就是这个顶点的位置,color
就是这个顶点的颜色。最终调用 EndPrimitive()
即将这个图元提交,输出给后面的着色器处理。
由此,我们就有了类似这样的流程,每次更改 gl_Position
和 color
后提交一个顶点
裁剪
丢弃掉画面外的顶点,只渲染画面内的顶点,这样提高了效率。
光栅化 插值
光栅化将顶点数据转换为片元,片元中每个元素对应了帧缓冲区的一个像素,这一步将几何图元转换为了像素。
插值是指对顶点数据进行插值,将插值结果传输给片段着色器。
片段着色器
片段着色器根据之前的插值结果计算最后输出到屏幕上的颜色,“片段”可以理解为像素。最简单的片段着色器只有输出颜色值。
例如 Shadertoy, Smoothstep,上面一些作品有不少都是直接编写 fragment shader 完成的。由于片段直接控制了输出到屏幕上的颜色,因此用法也相当丰富。
测试与混合
对于一些 3D 的场景,我们常常需要理清透视关系,例如我们不可能透过墙壁直接看到背后的东西。因此在这里有深度测试、混合等操作。
上面的这一系列操作,OpenGL 都需要去完成,而我们可以通过在其中干预介入的方式,在其中的一些着色器添加一些更多的效果。
其中,我们需要在顶点着色器做的事情都比较固定,就是从程序中读入顶点的坐标、颜色等属性,并将它们向后传递,然而这一步却是不能少的。几何着色器如果非必要,我们可以不进行额外的编程,但是相对来说,它的功能也相当的强大。片段着色器需要接收前面传来的顶点坐标、颜色等等属性,并最终输出到屏幕上。
3. 着色前的预备工作
向 OpenGL 传入数据
我们不妨先根据结果来推断原理:在 manim 中曲线是可以指定控制点坐标,可以设置颜色、可以设置线的宽度。
同时要注意到,我们所看到的这些复杂的曲线,都是用二阶贝塞尔曲线拼接成的。有拼接,就必定有转接处,否则就像砌墙面不砌墙角一样,会显得很丑,这个是我们需要考虑的一个点。
另外,manimgl 支持用列表来给定线的宽度和颜色,在传入的过程中,引擎会先对这个列表进行插值的操作,可以理解为:让列表的长度和顶点的数量一致。这样有一个好处,在传入 OpenGL 进行着色操作时,顶点、颜色、线宽这些属性都会一一对应,就像下面的表格一样
序号 | x 坐标 | y 坐标 | z 坐标 | color.r | color.g | color.b | color.a | stroke_width |
---|---|---|---|---|---|---|---|---|
0 | 0. | 0. | 0. | 1.0 | 0.5 | 0.0 | 1.0 | 6 |
1 | 1. | 1. | 0. | 1.0 | 1.0 | 0.0 | 1.0 | 4 |
2 | 2. | 1. | 0. | 0.0 | 0.5 | 1.0 | 0.5 | 2 |
当然,对于颜色、线宽传入单值的,应该也会有类似的对应关系。
这样,每一个顶点都有对应的属性,就避免了很多判断是否为空的情况了。
如何做到只给曲线着色
有 OpenGL 基础的同学应该能注意到,其自带的着色器只能绘制点、直线段、三角形(四边形应该不太常用),而像贝塞尔这样光滑的曲线,只用其基本元素来上色是几乎很难想象的。
然而,我们可以借助多种着色器的共同力量来画出这一条光滑的曲线(或弓形)。在这里,Grant 给出的方法是:先用一个多边形把曲线完全覆盖住,然后再把多余的部分擦除。这一部分在下面会更加详细地描述。
4. 二阶贝塞尔轮廓线着色
着色器功能
Vertex Shader
上面也提到了,顶点着色器的功能较为单一,大致就是传入顶点坐标、颜色、轮廓线宽等属性。
接着,我们考虑上面提到的曲线之间的转接处。一条曲线无非就是有前驱和后继两个转接点,当然如果是首部或者尾部那还可以少考虑情况,所以此处我们先把前驱顶点和后继顶点也传入近来,方便后续几何着色器创建更丰富的图元。
另外还有一个小细节,就是把相机角度给考虑进去,因为 manim 中还有一个比较奇葩的 is_fixed_in_frame
参数,它负责控制物件是否被锁定在画面顶层,不受相机的影响。其他的参数,就类似于一条管道一样一路向下传递。
Geometry Shader
几何着色器在这一步要做的事情就很多了,它也是相对来说比较复杂的。在这里,它的任务其实是创建合适的图元,将曲线完全覆盖。
在源码中,其开头是这样的
输入的图元是一系列的三角形(其本质是三角形的三个顶点),输出图元是用 triangle_strip 组成的最多五边形。
在以前的博客中也提到了,二阶贝塞尔曲线是由三个顶点生成的。而且由于其定义,它是由插值产生的,也就是说,曲线段不可能逃得出这个三角形的范围。当然,这是忽略了曲线的宽度才能考虑的,但我们所看到的曲线都是有宽度的,而就是因为这些“线宽”,它有一小部分无法被这个三角形覆盖。因此,我们可以考虑略微扩展这个三角形,使得它能够完全覆盖我们所想要画的曲线。
这里的例子就是将三角形扩展成五边形,来完全覆盖这条带有宽度的曲线。当然,如果这里的曲线是一条直线段,那么只需要用一个矩形即可将它覆盖。具体的实现原理,只需计算出顶点处的垂线(或者说是法向量),而后计算扩展的距离即可。
接下来我们要考虑的就是转接处。对于一个三角形(其他的一些也是类似的),我们想把它的拐角处变成尖尖的,而不是像几根粗木棍拼在一起。我们需要将矩形图元进一步扩展,补全它缺少的角落。
转接处的逻辑,大概就是对已经生成的图元的顶点添加一些偏移,补成这样的尖角。
好了,现在四边形/五边形的顶点已经计算出来了,现在只需要套用固定的格式,生成其对应的图元就可以了。
我们其实还少考虑了一些由摄像机角度造成的偏差,这一部分并非主要逻辑,只需稍作处理,一笔带过即可。
Fragment Shader
终于到了片段着色器,这一块的任务相对来说就比较“轻松”了。片段着色器要做的事情,其实就是把前面已经插值好的颜色填到图元上,再把多余的片段擦除即可。
我们需要用到的最主要的一个逻辑就是符号距离函数。符号距离函数负责算出所有在贝塞尔曲线范围内(其实应该被称为“弧块”)的片段(像素),每个片段的返回值都是一个浮点数,通过这个浮点数值的差异,再传入 smoothstep 变换其输出值,即可得到这样一些片段:
- 在曲线范围内的,值为 1
- 在曲线范围外的,值为 0
最后,我们把颜色的 alpha 通道乘上这个输出值,也就擦除了不需要的片段。
其实还涉及一些抗锯齿效果的因素,因为对于宏观理解差别不大,因此不过多的阐述。
到此为止,贝塞尔曲线轮廓线的着色程序结束。
流程图
5. 二阶贝塞尔曲边图形填充色
预备工作
上面的曲线轮廓着色有个特殊情况需要考虑,那就是需要额外去绘制两曲线之间的转接点。而对于曲边图形的着色,其逻辑相对来说更加复杂。
对于填充色,使用 OpenGL 来进行操作的话,那就是面着色,于是就基本规定了必须用 triangle_strip 来着色。于是我们有了以下的考虑:
- 对于普通的直边多边形,我们可以将它剖分成一系列的三角形,从而用这些三角形带来覆盖多边形
- 对于曲边的多边形,我们不妨先把这些曲边割掉,先只考虑内部的多边形,这一部分可以用上面相似的方法来操作。而对于边缘的一些弓形,我们需要额外处理:先用一个三角形将弓形完全覆盖,再将多余的部分擦除。
其中,三角剖分就起了非常重要的作用,它负责把多边形划分成一系列三角形,将这一系列三角形(顶点和顶点索引)传入 OpenGL 进行上色操作。
着色器功能
Vertex Shader
顶点着色器的功能仍然是传入顶点、颜色、法向量、以及顶点索引,再加上处理相机视角的一些细节。之后就是将这些属性继续向后传递。
Geometry Shader
几何着色器的开头声明如下
输入图元依然为三角形(本质上是三个顶点),输出图元最多为五边形。
这里的几何着色器程序输出的图元有两种:三角图元和五边形图元,它们分别对应着多边形的内部三角和边缘的弓形。至于为何要使用五边形,是因为边缘的弓形需要增加一些抗锯齿效果,从宏观理解上只需把它当作三角形来理解即可。
由于程序已经做了三角剖分的操作,因此内部的图元可以直接按照原本传入的三角形输出即可。但上述的两种不同情况如何才能区分呢?这就需要请出顶点索引这个要素了。
我们不妨先来看一个圆的三角剖分
三角剖分后的索引数组如下
三个为一组进行分割,第一行为所有弓形的顶点索引,而第二行是内部三角形的顶点索引(第一组好像没什么用)。我们发现,所有内部三角形的顶点索引几乎都是不连续的自然数,而弓形的顶点索引几乎都是相邻的自然数。由这一点差别,我们可以区分某一个图元是按照普通三角形着色,还是按照弓形来着色。
由于弓形还分凸弓形和凹弓形,因此在几何着色器还计算了应该朝哪一个方向上色按照三角形/弓形上色的标志 fill_all 也将被继续传入到 fragment shader 进行下一步处理。
于是我们套用输出图元的格式,将计算好的三角图元和五边形图元输出。
Fragment Shader
片段着色器此处要做的工作,几乎可以认为是只需把计算好的颜色涂到片段上就可以了。然而如果我们真的仅仅这么做的话就会得到这样的结果。
我们忘记把不需要的片段擦除了。此时还是请出 sdf 符号距离函数,根据上一步的 orientation ,来判断给弓形的内部还是外部上色。接着计算出需要保留的片段(像素),对每个片段 sdf 都输出一个 float 值,根据这个浮点值的差异,传入 smoothstep 变换其输出值,使得满足下面的情况:
- 需要的片段,返回值为 1
- 不需要的片段,返回值为 0
将这一输出值乘在 color 的 alpha 分量上,也就使得不需要的片段被擦除了。
至此,贝塞尔曲线图形的填充色程序完成。
流程图
#manim
#矢量图
#OpenGL
#bezier