manimgl 二阶贝塞尔曲线的上色机制
1. 前言
大三下学期好巧不巧的选了一个数据可视化的课,然后就借此机会学了一点点 OpenGL 的知识。于是就想借着这份兴趣,把没人去研究的 manimgl shader 部分稍微研究一下,也可以作为自己的一点学习经验吧。
虽说选上的这门课很无聊,而且选这门课的人只有 20 多位,可想而知这是有多劝退啊。但从另一方面想,其实也应该算是一个比较好的契机,毕竟之前大二寒假学的 glsl 也只能瞎画画,做不了什么比较出色的作品。同时,我也 fork 了一个 OpenGL 开发环境的仓库,它是搭建在 Windows 端的,而我也一步步踩坑,才成功搭起了 macOS 的开发环境 总之搭环境真累 。仓库指路
吐槽
这里吐槽一下老师,上课基本上都只讲一些理论的东西,OpenGL 等等实践类的操作全都让我们课后自学,这方面我觉得没有什么太大的问题。但最最不能忍受的就是,一位北大博士生,还在那一个劲的给我们安利 VC++6.0,我想老师一定没写过代码吧,毕竟听科研组的同学说,老师说不定已经好几年没写过代码了。另外,为什么北大博士生会屈居普通本科大学教书呢?这还挺值得深思。
在此还要感谢 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