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 函数会对传入的两个值进行插值运算,即有

genType mix(genType x, genType y, float a) {
return (1. - a) * x + a * y;
}

smoothstep

泛型有

genType smoothstep(genType edge_0, genType edge_1, genType x);

对于浮点类型有

float smoothstep(float edge_0, float edge_1, float x);

它满足下面的性质

  • x<edge0\displaystyle{ x < \text{edge} _{ 0 } } 时,返回值为 0
  • x>edge1\displaystyle{ x > \text{edge} _{ 1 } } 时,返回值为 1
  • edge0<x<edge1\displaystyle{ \text{edge} _{ 0 } < x < \text{edge} _{ 1 } } 时,返回值为从 0 到 1 的一个平滑插值

特别的,如果 edge1<edge0\displaystyle{ \text{edge} _{ 1 } < \text{edge} _{ 0 } } 时,满足下面的性质

  • x<edge1\displaystyle{ x < \text{edge} _{ 1 } } 时,返回值为 1
  • x>edge0\displaystyle{ x > \text{edge} _{ 0 } } 时,返回值为 0
  • edge1<x<edge0\displaystyle{ \text{edge} _{ 1 } < x < \text{edge} _{ 0 } } 时,返回值为从 1 到 0 的一个平滑插值

sdf符号距离函数

这是图形上色的一个概念,在空间的一个有限区域上确定一个点到边界的距离,并用符号来定义距离

  • 在边界内部的,符号定义为正
  • 在边界外部的,符号定义为负

听上去还是云里雾里的,那举个例子吧。假设我们想要画一个圆,我们首先会想到,定义一个函数,返回值为 4 分量的颜色,然后调用这个函数,把得到的颜色直接涂到片段 frag_color 上。

vec4 circle(vec2 coord, vec2 pos, float radius) {
if (length(coord - pos) <= radius) {
return vec4(1.0, 1.0, 0.0, 1.0);
} else {
return vec4(0.0);
}
}

“这样一定很完美吧。”我们可能会这么想。但如果图形很复杂,需要嵌套非常多的 if-else 呢?这样就显得很不优雅了罢。而且这样做,会把先前已经上过色的片段给覆盖掉,于是我们需要寻找更好的方案,来代替这种浅显的方法。

此时,我们回到符号距离函数上来,即用符号来定义距离。我们让圆内的点,返回值都大于 0,圆外的点,返回值都小于 0。此时,函数的返回值就是一个 float 类型。

float sdfCircle(vec2 coord, vec2 pos, float radius) {
return radius - length(coord - pos);
}

这时,我们已经成功用符号来区分片段了。那么怎样才能将这种区分体现到着色的片段上呢?这就需要使用 mix 函数了。于是,我们根据刚才得到的 sdf,尝试一下。

float f = sdfCircle(coord, vec2(0.), 1.);
color = mix(color, vec3(1.), f);
展开源码
float grid(vec2 uv) {
return float(floor(mod(uv, 2.).x) == floor(mod(uv, 2.).y));
}
float sdfCircle(vec2 coord, vec2 pos, float radius) {
return radius - length(coord - pos);
}
void main() {
vec3 color = vec3(0.);
vec2 uv = fixUV(gl_FragCoord.xy);
color = mix(color, vec3(0.3), grid(uv));
float f = sdfCircle(uv, vec2(0.), 1.);
vec3 c = clamp(mix(vec3(0.), vec3(1.), f), vec3(0.), vec3(1.));
color = c + color;
gl_FragColor = vec4(color, 1.);
}

好像这个效果不尽如人意诶,毕竟这个 sdf 也只是区分了一下符号,我们想要把大于 0 的片段,全部使用 vec3(1.) 的颜色,而小于 0 的片段,全部使用原来的颜色片段。

此时我们请出 smoothstep 函数,让小于 0 的片段结果都为 0,大于 0 的片段都为 1,我们可以得到这样的结果

float f = smoothstep(0., fwidth(uv.x), sdfCircle(coord, vec2(0.), 1.));
color = mix(color, vec3(1.), f);
展开源码
float grid(vec2 uv) {
return float(floor(mod(uv, 2.).x) == floor(mod(uv, 2.).y));
}
float sdfCircle(vec2 coord, vec2 pos, float radius) {
return radius - length(coord - pos);
}
void main() {
vec3 color = vec3(0.);
vec2 uv = fixUV(gl_FragCoord.xy);
color = mix(color, vec3(0.2), grid(uv));
float f = smoothstep(0., 0.02, sdfCircle(uv, vec2(0.), 1.));
color = mix(color, vec3(1.), f);
gl_FragColor = vec4(color, 1.);
}

同时我们也发现,由于 smoothstep 的平滑补间效果,图形边缘的锯齿也改善了很多,在此处看得其实不是很清晰

OpenGL 运行流程

总体流程图

_public/posts-imgs/opengl-progress.excalidraw.svg

顶点数组

首先,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_adjacencyGL_LINES_ADJACENCY或GL_LINE_STRIP_ADJACENCY4
trianglesGL_TRIANGLES、GL_TRIANGLE_STRIP或GL_TRIANGLE_FAN3
triangles_adjacencyGL_TRIANGLES_ADJACENCY或GL_TRIANGLE_STRIP_ADJACENCY6

同时,需要在头部加上输入布局修饰符

layout (triangles) in; // 输入图元为三角形
输出类型说明
points
line_strip线
triangle_strip三角形

在头部加上输出布局修饰符,几何着色器同时希望我们设置一个它最大能够输出的顶点数量(如果你超过了这个值,OpenGL 将不会绘制多出的顶点),这个也可以在 out 关键字的布局修饰符中设置。

layout (triangle_strip, max_vertices = 5) out; // 输出图元为三角形,最大顶点数为 5
图元创建

在 C++ 的 std::cout 中,我们将想要输出的变量插入到流中,最后加上 std::endl 刷新缓冲区,将这些数据输出在控制台上。

对于图元也是可以用这样类似的方法来理解。例如我们就拿上面给到的,输入图元为三角形,输出图元最大顶点数为 5 作为例子。假设有输入输出变量如下:

in vec3 v_points[3]; // 输入图元的三个顶点
in vec4 v_color[3]; // 三个顶点对应的颜色
out vec4 color; // 输出颜色

接下来更改 gl_Positioncolor,在调用 EmitVertex() 时将该顶点提交到输出图元,其中 gl_Position 就是这个顶点的位置,color 就是这个顶点的颜色。最终调用 EndPrimitive() 即将这个图元提交,输出给后面的着色器处理。

由此,我们就有了类似这样的流程,每次更改 gl_Positioncolor 后提交一个顶点

Primitive << gl_Position << color << Emit
<< gl_Position << color << Emit
<< ...
<< gl_Position << color << Emit << EndPrimitive;
裁剪

丢弃掉画面外的顶点,只渲染画面内的顶点,这样提高了效率。

光栅化 插值

光栅化将顶点数据转换为片元,片元中每个元素对应了帧缓冲区的一个像素,这一步将几何图元转换为了像素。

插值是指对顶点数据进行插值,将插值结果传输给片段着色器。

片段着色器

片段着色器根据之前的插值结果计算最后输出到屏幕上的颜色,“片段”可以理解为像素。最简单的片段着色器只有输出颜色值。

例如 Shadertoy, Smoothstep,上面一些作品有不少都是直接编写 fragment shader 完成的。由于片段直接控制了输出到屏幕上的颜色,因此用法也相当丰富。

测试与混合

对于一些 3D 的场景,我们常常需要理清透视关系,例如我们不可能透过墙壁直接看到背后的东西。因此在这里有深度测试、混合等操作。

上面的这一系列操作,OpenGL 都需要去完成,而我们可以通过在其中干预介入的方式,在其中的一些着色器添加一些更多的效果。

其中,我们需要在顶点着色器做的事情都比较固定,就是从程序中读入顶点的坐标、颜色等属性,并将它们向后传递,然而这一步却是不能少的。几何着色器如果非必要,我们可以不进行额外的编程,但是相对来说,它的功能也相当的强大。片段着色器需要接收前面传来的顶点坐标、颜色等等属性,并最终输出到屏幕上。

3. 着色前的预备工作

向 OpenGL 传入数据

我们不妨先根据结果来推断原理:在 manim 中曲线是可以指定控制点坐标,可以设置颜色、可以设置线的宽度

同时要注意到,我们所看到的这些复杂的曲线,都是用二阶贝塞尔曲线拼接成的。有拼接,就必定有转接处,否则就像砌墙面不砌墙角一样,会显得很丑,这个是我们需要考虑的一个点。

另外,manimgl 支持用列表来给定线的宽度和颜色,在传入的过程中,引擎会先对这个列表进行插值的操作,可以理解为:让列表的长度和顶点的数量一致。这样有一个好处,在传入 OpenGL 进行着色操作时,顶点、颜色、线宽这些属性都会一一对应,就像下面的表格一样

序号x 坐标y 坐标z 坐标color.rcolor.gcolor.bcolor.astroke_width
00.0.0.1.00.50.01.06
11.1.0.1.01.00.01.04
22.1.0.0.00.51.00.52

当然,对于颜色、线宽传入单值的,应该也会有类似的对应关系。

这样,每一个顶点都有对应的属性,就避免了很多判断是否为空的情况了。

如何做到只给曲线着色

有 OpenGL 基础的同学应该能注意到,其自带的着色器只能绘制直线段三角形(四边形应该不太常用),而像贝塞尔这样光滑的曲线,只用其基本元素来上色是几乎很难想象的。

然而,我们可以借助多种着色器的共同力量来画出这一条光滑的曲线(或弓形)。在这里,Grant 给出的方法是:先用一个多边形把曲线完全覆盖住,然后再把多余的部分擦除。这一部分在下面会更加详细地描述。

4. 二阶贝塞尔轮廓线着色

着色器功能

Vertex Shader

上面也提到了,顶点着色器的功能较为单一,大致就是传入顶点坐标、颜色、轮廓线宽等属性。

接着,我们考虑上面提到的曲线之间的转接处。一条曲线无非就是有前驱和后继两个转接点,当然如果是首部或者尾部那还可以少考虑情况,所以此处我们先把前驱顶点和后继顶点也传入近来,方便后续几何着色器创建更丰富的图元。

另外还有一个小细节,就是把相机角度给考虑进去,因为 manim 中还有一个比较奇葩的 is_fixed_in_frame 参数,它负责控制物件是否被锁定在画面顶层,不受相机的影响。其他的参数,就类似于一条管道一样一路向下传递。

Geometry Shader

几何着色器在这一步要做的事情就很多了,它也是相对来说比较复杂的。在这里,它的任务其实是创建合适的图元,将曲线完全覆盖

在源码中,其开头是这样的

layout (triangles) in; // 输入图元
layout (triangle_strip, max_vertices = 5) out; // 输出图元

输入的图元是一系列的三角形(其本质是三角形的三个顶点),输出图元是用 triangle_strip 组成的最多五边形。

在以前的博客中也提到了,二阶贝塞尔曲线是由三个顶点生成的。而且由于其定义,它是由插值产生的,也就是说,曲线段不可能逃得出这个三角形的范围。当然,这是忽略了曲线的宽度才能考虑的,但我们所看到的曲线都是有宽度的,而就是因为这些“线宽”,它有一小部分无法被这个三角形覆盖。因此,我们可以考虑略微扩展这个三角形,使得它能够完全覆盖我们所想要画的曲线。

_public/posts-imgs/curve_stroke_primitive.png

这里的例子就是将三角形扩展成五边形,来完全覆盖这条带有宽度的曲线。当然,如果这里的曲线是一条直线段,那么只需要用一个矩形即可将它覆盖。具体的实现原理,只需计算出顶点处的垂线(或者说是法向量),而后计算扩展的距离即可。

接下来我们要考虑的就是转接处。对于一个三角形(其他的一些也是类似的),我们想把它的拐角处变成尖尖的,而不是像几根粗木棍拼在一起。我们需要将矩形图元进一步扩展,补全它缺少的角落。

_public/posts-imgs/quadratic_corner_stroke.png

转接处的逻辑,大概就是对已经生成的图元的顶点添加一些偏移,补成这样的尖角。

好了,现在四边形/五边形的顶点已经计算出来了,现在只需要套用固定的格式,生成其对应的图元就可以了。

我们其实还少考虑了一些由摄像机角度造成的偏差,这一部分并非主要逻辑,只需稍作处理,一笔带过即可。

Fragment Shader

终于到了片段着色器,这一块的任务相对来说就比较“轻松”了。片段着色器要做的事情,其实就是把前面已经插值好的颜色填到图元上,再把多余的片段擦除即可。

我们需要用到的最主要的一个逻辑就是符号距离函数。符号距离函数负责算出所有在贝塞尔曲线范围内(其实应该被称为“弧块”)的片段(像素),每个片段的返回值都是一个浮点数,通过这个浮点数值的差异,再传入 smoothstep 变换其输出值,即可得到这样一些片段:

  • 在曲线范围内的,值为 1
  • 在曲线范围外的,值为 0

最后,我们把颜色的 alpha 通道乘上这个输出值,也就擦除了不需要的片段。

_public/posts-imgs/curve_stroke_shader.png

其实还涉及一些抗锯齿效果的因素,因为对于宏观理解差别不大,因此不过多的阐述。

到此为止,贝塞尔曲线轮廓线的着色程序结束。

流程图

_public/gl/quadratic-bezier-stroke-shader.excalidraw.svg

5. 二阶贝塞尔曲边图形填充色

预备工作

上面的曲线轮廓着色有个特殊情况需要考虑,那就是需要额外去绘制两曲线之间的转接点。而对于曲边图形的着色,其逻辑相对来说更加复杂。

对于填充色,使用 OpenGL 来进行操作的话,那就是面着色,于是就基本规定了必须用 triangle_strip 来着色。于是我们有了以下的考虑:

  • 对于普通的直边多边形,我们可以将它剖分成一系列的三角形,从而用这些三角形带来覆盖多边形
  • 对于曲边的多边形,我们不妨先把这些曲边割掉,先只考虑内部的多边形,这一部分可以用上面相似的方法来操作。而对于边缘的一些弓形,我们需要额外处理:先用一个三角形将弓形完全覆盖,再将多余的部分擦除

_public/posts-imgs/example_trianglify.svg

其中,三角剖分就起了非常重要的作用,它负责把多边形划分成一系列三角形,将这一系列三角形(顶点和顶点索引)传入 OpenGL 进行上色操作。

着色器功能

Vertex Shader

顶点着色器的功能仍然是传入顶点、颜色、法向量、以及顶点索引,再加上处理相机视角的一些细节。之后就是将这些属性继续向后传递。

Geometry Shader

几何着色器的开头声明如下

layout (triangles) in; // 输入图元
layout (triangle_strip, max_vertices = 5) out; // 输出图元

输入图元依然为三角形(本质上是三个顶点),输出图元最多为五边形。

这里的几何着色器程序输出的图元有两种:三角图元五边形图元,它们分别对应着多边形的内部三角边缘的弓形。至于为何要使用五边形,是因为边缘的弓形需要增加一些抗锯齿效果,从宏观理解上只需把它当作三角形来理解即可。

由于程序已经做了三角剖分的操作,因此内部的图元可以直接按照原本传入的三角形输出即可。但上述的两种不同情况如何才能区分呢?这就需要请出顶点索引这个要素了。

我们不妨先来看一个圆的三角剖分

_public/posts-imgs/circle_trianglify.png

三角剖分后的索引数组如下

[ 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
21 23 0 0 3 6 6 9 12 12 15 18 18 21 0 0 6 12 12 18 0]

三个为一组进行分割,第一行为所有弓形的顶点索引,而第二行是内部三角形的顶点索引(第一组好像没什么用)。我们发现,所有内部三角形的顶点索引几乎都是不连续的自然数,而弓形的顶点索引几乎都是相邻的自然数。由这一点差别,我们可以区分某一个图元是按照普通三角形着色,还是按照弓形来着色。

由于弓形还分凸弓形和凹弓形,因此在几何着色器还计算了应该朝哪一个方向上色按照三角形/弓形上色的标志 fill_all 也将被继续传入到 fragment shader 进行下一步处理。

于是我们套用输出图元的格式,将计算好的三角图元和五边形图元输出。

Fragment Shader

片段着色器此处要做的工作,几乎可以认为是只需把计算好的颜色涂到片段上就可以了。然而如果我们真的仅仅这么做的话就会得到这样的结果。

_public/posts-imgs/fill_without_sdf.png

我们忘记把不需要的片段擦除了。此时还是请出 sdf 符号距离函数,根据上一步的 orientation ,来判断给弓形的内部还是外部上色。接着计算出需要保留的片段(像素),对每个片段 sdf 都输出一个 float 值,根据这个浮点值的差异,传入 smoothstep 变换其输出值,使得满足下面的情况:

  • 需要的片段,返回值为 1
  • 不需要的片段,返回值为 0

将这一输出值乘在 color 的 alpha 分量上,也就使得不需要的片段被擦除了。

_public/posts-imgs/curve_shader.png

至此,贝塞尔曲线图形的填充色程序完成。

流程图

_public/gl/quadratic-bezier-fill-shader.excalidraw.svg

#manim #矢量图 #OpenGL #bezier