基元处理 - 曲面细分
从这篇文章开始,我们就开始接触曲面细分着色器了。如果接触过 3D 建模软件,对曲面细分这个名词就不会陌生。在建模中使用曲面细分功能,在现有面上额外增加一些线面,从而进行后续的计算处理时,能让模型变得更加细腻。同样,在 OpenGL 中,对于曲面细分着色器最直观的感受,也是“增加一些线面”。
OpenGL 曲面细分的设置过程如图 1 所示,它涉及到曲面细分控制着色器和曲面细分评估着色器,曲面细分引擎是固定的。

曲面细分控制着色器接受来自顶点着色器的输入,并需要设置 gl_TessLevelInner 和 gl_TessLevelOuter 参数给曲面细分引擎。需要注意的是,曲面细分控制着色器会将面片涉及到的控制点的参数批量传递给后续曲面细分评估着色器。
曲面细分评估着色器根据曲面细分控制着色器的输出以及曲面细分引擎的输出 gl_TessCoord,可以根据规则将面片拆分成更小的面片。接着输出至后续的基元装配。
以上是对曲面细分流程的大致熟悉,结合后续代码处理会有进一步的感受。
曲面细分基元模式
面片如何拆分,各种基元模式下会有些差别。涉及到的基元模式有四视图、三角形和等值线。我们一个个来进行实验。
使用四视图的曲面细分
我们直接看到曲面细分的着色器代码,顶点着色器和片段着色器很简单(像这里的四视图就是四个点),就不贴出了。
- #version 420 core
- layout (vertices = 4) out;
- void main(void)
- {
- if (gl_InvocationID == 0)
- {
- gl_TessLevelInner[0] = 9.0;
- gl_TessLevelInner[1] = 7.0;
- gl_TessLevelOuter[0] = 3.0;
- gl_TessLevelOuter[1] = 5.0;
- gl_TessLevelOuter[2] = 3.0;
- gl_TessLevelOuter[3] = 5.0;
- }
- gl_out[gl_InvocationID].gl_Position = gl_in[gl_InvocationID].gl_Position;
- }
清单 1.1.1 是四视图的曲面细分控制着色器代码。之前已经提及曲面细分作用于面片,此例子中的四视图对应 4 个顶点,使用了 layout (vertices = 4) out 进行申明。因为面片信息需要批量传递,所以涉及到了目前第一次接触到的 gl_in 和 gl_out 内置数组变量,它就是用来批量传递顶点信息的。而 gl_InvocationID 就是根据设置的 vertices = 4 依次进行索引的。这边我们可以看到各个顶点的位置信息无需改变,只是进行了简单的复制。
清单 1.1.1 中还出现了图 1 中介绍的 gl_TessLevelInner 和 gl_TessLevelOuter 参数。gl_TessLevelInner 确定最内层区域的曲面细分等级;gl_TessLevelOuter 确定外边缘的曲面细分等级。这两个参数各个下标索引对应的边如图 2 所示。

gl_TessLevelInner 和 gl_TessLevelOuter 参数不难理解,结合代码中设置的等级,按着图 3 的结果图,各边数一数,内部数一数,很好感受。

再看到清单 1.1.2 中的曲面细分评估着色器,首先开头的 layout (quads) in 指明是四视图模式。这边开始涉及到图 1 中提及的 gl_TessCoord 变量。这个变量只知道是和曲面细分插值坐标有关,其余介绍信息非常少,很有“门道”,通过 OpenGL 手册中能明确确定的就是这个变量的坐标范围在 0 和 1 之间。
- #version 420 core
- layout (quads) in;
- void main(void)
- {
- vec4 p1 = mix(gl_in[0].gl_Position, gl_in[1].gl_Position, gl_TessCoord.x);
- vec4 p2 = mix(gl_in[2].gl_Position, gl_in[3].gl_Position, gl_TessCoord.x);
- gl_Position = mix(p1, p2, gl_TessCoord.y);
- }
就单单四视图这个场景,能找到图 4 的插值算法,同时结合代码,可以发现算法是一致。这个插值算法不了解,引擎输出逻辑不了解,这边就暂把曲面细分评估着色器中的这个逻辑当成是固定的。

使用三角形的曲面细分
与四视图类似,我们先看到三角形模式的曲面细分控制着色器。如代码清单 1.2.1 所示,此例三角形对应的 vertices = 3。图 5 是 gl_TessLevelInner 和 gl_TessLevelOuter 参数的对应关系。
- #version 420 core
- layout (vertices = 3) out;
- void main()
- {
- if (gl_InvocationID == 0)
- {
- gl_TessLevelInner[0] = 5.0;
- gl_TessLevelOuter[0] = 8.0;
- gl_TessLevelOuter[1] = 8.0;
- gl_TessLevelOuter[2] = 8.0;
- }
- gl_out[gl_InvocationID].gl_Position = gl_in[gl_InvocationID].gl_Position;
- }

曲面细分评估着色器如代码清单 1.2.2 所示,可以看到这边 gl_TessCoord 的含义和四视图不同了,官方的说法是重心坐标。同样,我们也先把当成是一个固定的处理方式吧。
- #version 420 core
- layout (triangles) in;
- void main()
- {
- gl_Position = (gl_TessCoord.x * gl_in[0].gl_Position) +
- (gl_TessCoord.y * gl_in[1].gl_Position) +
- (gl_TessCoord.z * gl_in[2].gl_Position);
- }
图 6 是三角形曲面细分的结果图。

使用等值线的曲面细分
等值线模式有点和三维建模软件增加细分数的外观类似。曲面细分控制器中的 gl_TessLevelInner 和 gl_TessLevelOuter 参数的对应关系如图 7 所示;曲面细分评估着色器中的点生成方式和四视图一样,代码就不重复给出了。最终的效果如图 8 所示。


如果能详细了解曲面细分评估着色器中生成点的方式,提供给开发者的自由度还是很大的,如代码清单 1.3.1 中实现的等值螺旋线,效果如图 9 所示。但是,此例的实现逻辑还是不太清楚,只知道是将坐标转换了极坐标方式。所以,还是如前面反复提及的,现在可以把曲面细分评估着色器当成一个半固定的流程去理解,可以免去不少纠结。
- #version 420 core
- layout (isolines) in;
- void main(void)
- {
- float r = gl_TessCoord.y + gl_TessCoord.x / gl_TessLevelOuter[0];
- float t = gl_TessCoord.x * 2.0 * 3.14159;
- gl_Position = vec4(sin(t) * r, cos(t) * r, 0.5, 1.0);
- }

现在将曲面细分评估着色器当成半固定的流程。其中涉及的详细逻辑或算法,先存疑记录。
曲面细分示例:地形渲染
虽然现在对点生成的方式不理解,但是在其给定的现有流程上进行增加修改还是不难的。比如这节要说明的地形渲染例子,我们只要在曲面细分的基础上,额外将高度对应的坐标加上一定值就可以了,以达到山脉突起的效果。
高度额外增加的值通过二维索引数据指定,通过纹理数据访问。因为都是二进制数据,所以转化成纹理图片也没什么好看的。但是可以通过颜色深浅大致了解一下高度的分布状况,其内容如图 10 所示。

首先看到代码清单 2.1 所示的顶点着色器,和之前草坪的示例一样,它通过实例化渲染。其同样也通过 gl_InstanceID 变量生成不同的 x、y 偏移,从而指定不同的纹理坐标和顶点坐标。
- #version 450 core
- out VS_OUT
- {
- vec2 tc;
- } vs_out;
- void main(void)
- {
- const vec4 vertices[] = vec4[](vec4(-0.5, 0.0, -0.5, 1.0),
- vec4( 0.5, 0.0, -0.5, 1.0),
- vec4(-0.5, 0.0, 0.5, 1.0),
- vec4( 0.5, 0.0, 0.5, 1.0));
- int x = gl_InstanceID & 63;
- int y = gl_InstanceID >> 6;
- vec2 offs = vec2(x, y);
- vs_out.tc = (vertices[gl_VertexID].xz + offs + vec2(0.5)) / 64.0;
- gl_Position = vertices[gl_VertexID] + vec4(float(x - 32), 0.0, float(y - 32), 0.0);
- }
曲面细分控制着色器代码如清单 2.2 所示,这边的参数设置工作都在前一节学习过了。需要特别注意的是数据是如何传递的:顶点着色器的 VS_OUT 结构体,传到曲面细分控制着色器就会和 gl_in 变量变成数组形式,因为包含了多个顶点信息。同样,如果还需要把数据继续传递到曲面细分评估着色器,还是需要以数组的形式传递。
- #version 450 core
- layout (vertices = 4) out;
- in VS_OUT
- {
- vec2 tc;
- } tcs_in[];
- out TCS_OUT
- {
- vec2 tc;
- } tcs_out[];
- uniform mat4 mvp_matrix;
- void main(void)
- {
- if (gl_InvocationID == 0)
- {
- vec4 p0 = mvp_matrix * gl_in[0].gl_Position;
- vec4 p1 = mvp_matrix * gl_in[1].gl_Position;
- vec4 p2 = mvp_matrix * gl_in[2].gl_Position;
- vec4 p3 = mvp_matrix * gl_in[3].gl_Position;
- p0 /= p0.w;
- p1 /= p1.w;
- p2 /= p2.w;
- p3 /= p3.w;
- if (p0.z <= 0.0 || p1.z <= 0.0 || p2.z <= 0.0 || p3.z <= 0.0)
- {
- gl_TessLevelOuter[0] = 0.0;
- gl_TessLevelOuter[1] = 0.0;
- gl_TessLevelOuter[2] = 0.0;
- gl_TessLevelOuter[3] = 0.0;
- }
- else
- {
- float l0 = length(p2.xy - p0.xy) * 16.0 + 1.0;
- float l1 = length(p3.xy - p2.xy) * 16.0 + 1.0;
- float l2 = length(p3.xy - p1.xy) * 16.0 + 1.0;
- float l3 = length(p1.xy - p0.xy) * 16.0 + 1.0;
- gl_TessLevelOuter[0] = l0;
- gl_TessLevelOuter[1] = l1;
- gl_TessLevelOuter[2] = l2;
- gl_TessLevelOuter[3] = l3;
- gl_TessLevelInner[0] = min(l1, l3);
- gl_TessLevelInner[1] = min(l0, l2);
- }
- }
- gl_out[gl_InvocationID].gl_Position = gl_in[gl_InvocationID].gl_Position;
- tcs_out[gl_InvocationID].tc = tcs_in[gl_InvocationID].tc;
- }
曲面细分评估着色器代码如清单 2.3 所示,其中纹理坐标和顶点坐标都按照上节四视图的插值算法进行。注意到 32 行,y 坐标从图 10 所示的纹理数据中索引高度偏移。后续连接的是片段着色器,所以输出数据就不需要以数组的形式了。
- #version 450 core
- layout (quads, fractional_odd_spacing) in;
- layout (binding = 0) uniform sampler2D tex_displacement;
- uniform mat4 mv_matrix;
- uniform mat4 proj_matrix;
- uniform float dmap_depth;
- in TCS_OUT
- {
- vec2 tc;
- } tes_in[];
- out TES_OUT
- {
- vec2 tc;
- vec3 world_coord;
- vec3 eye_coord;
- } tes_out;
- void main(void)
- {
- vec2 tc1 = mix(tes_in[0].tc, tes_in[1].tc, gl_TessCoord.x);
- vec2 tc2 = mix(tes_in[2].tc, tes_in[3].tc, gl_TessCoord.x);
- vec2 tc = mix(tc2, tc1, gl_TessCoord.y);
- vec4 p1 = mix(gl_in[0].gl_Position, gl_in[1].gl_Position, gl_TessCoord.x);
- vec4 p2 = mix(gl_in[2].gl_Position, gl_in[3].gl_Position, gl_TessCoord.x);
- vec4 p = mix(p2, p1, gl_TessCoord.y);
- p.y += texture(tex_displacement, tc).r * dmap_depth;
- vec4 P_eye = mv_matrix * p;
- tes_out.tc = tc;
- tes_out.world_coord = p.xyz;
- tes_out.eye_coord = P_eye.xyz;
- gl_Position = proj_matrix * P_eye;
- }
最后是片段着色器,如代码清单 2.4 所示。使用输入的纹理坐标索引如图 11 所示的颜色纹理信息。
- #version 450 core
- out vec4 color;
- layout (binding = 1) uniform sampler2D tex_color;
- uniform bool enable_fog = true;
- uniform vec4 fog_color = vec4(0.7, 0.8, 0.9, 0.0);
- in TES_OUT
- {
- vec2 tc;
- vec3 world_coord;
- vec3 eye_coord;
- } fs_in;
- vec4 fog(vec4 c)
- {
- float z = length(fs_in.eye_coord);
- float de = 0.025 * smoothstep(0.0, 6.0, 10.0 - fs_in.world_coord.y);
- float di = 0.045 * smoothstep(0.0, 40.0, 20.0 - fs_in.world_coord.y);
- float extinction = exp(-z * de);
- float inscattering = exp(-z * di);
- return c * extinction + fog_color * (1.0 - inscattering);
- }
- void main(void)
- {
- vec4 landscape = texture(tex_color, fs_in.tc);
- if (enable_fog)
- {
- color = fog(landscape);
- }
- else
- {
- color = landscape;
- }
- }

图 12 就是最终使用曲面细分渲染的地形。

曲面细分示例:三次贝塞尔曲面
关于这个例子,如果盯着 gl_TessCoord 变量的范围在 0 和 1 之间的事实,再套用贝塞尔曲线的公式的话,还是很容易理解的。
三次贝塞尔曲线 的公式如下所示,其中 \(t\) 的取值范围是 0 到 1,gl_TessCoord 正好可以和它对应:
- \(Pe(t)=(1-t)P_0+tP_1\)
- \(Pf(t)=(1-t)P_1+tP_2\)
- \(Pg(t)=(1-t)P_2+tP_3\)
- \(Ph(t)=(1-t)Pe(t)+tPf(t)\)
- \(Pi(t)=(1-t)Pf(t)+tPg(t)\)
- \(B(t)=(1-t)Ph(t)+tPi(t)\)
知道了三次贝塞尔曲线公式,可以从 图 13 中了解如何生成贝塞尔曲面。片面上有 16 个顶点,先纵向 4 个点有 4 组,分别可以确定 4 条贝塞尔曲线,接着使用这 4 条贝塞尔曲面上的 4 个点,再生成一条贝塞尔曲面,这就是面上的各个顶点。

依据以上的结论,我们看到清单 3.1 中的三次贝塞尔曲面细分控制着色器代码。其中顶点需要 16 个(第 3 行),并设置内外细分参数为 16。
- #version 410 core
- layout (vertices = 16) out;
- void main(void)
- {
- uint id = gl_InvocationID;
- if (id == 0)
- {
- gl_TessLevelInner[0] = 16.0;
- gl_TessLevelInner[1] = 16.0;
- gl_TessLevelOuter[0] = 16.0;
- gl_TessLevelOuter[1] = 16.0;
- gl_TessLevelOuter[2] = 16.0;
- gl_TessLevelOuter[3] = 16.0;
- }
- gl_out[gl_InvocationID].gl_Position = gl_in[id].gl_Position;
- }
代码清单 3.2 是曲面细分评估着色器,依据以上的三次贝塞尔曲线公式,可以很好对应 quadratic_bezier、cubic_bezier 和 evaluate_patch 这些函数。先通过 gl_TessCoord.y 获得 4 个点,接着根据 gl_TessCoord.x 获得面上的点。
- #version 410 core
- layout (quads, equal_spacing, cw) in;
- uniform mat4 mv_matrix;
- uniform mat4 proj_matrix;
- uniform mat4 mvp;
- out TES_OUT
- {
- vec3 N;
- } tes_out;
- vec4 quadratic_bezier(vec4 A, vec4 B, vec4 C, float t)
- {
- vec4 D = mix(A, B, t);
- vec4 E = mix(B, C, t);
- return mix(D, E, t);
- }
- vec4 cubic_bezier(vec4 A, vec4 B, vec4 C, vec4 D, float t)
- {
- vec4 E = mix(A, B, t);
- vec4 F = mix(B, C, t);
- vec4 G = mix(C, D, t);
- return quadratic_bezier(E, F, G, t);
- }
- vec4 evaluate_patch(vec2 at)
- {
- vec4 P[4];
- int i;
- for (i = 0; i < 4; i++)
- {
- P[i] = cubic_bezier(gl_in[i + 0].gl_Position,
- gl_in[i + 4].gl_Position,
- gl_in[i + 8].gl_Position,
- gl_in[i + 12].gl_Position,
- at.y);
- }
- return cubic_bezier(P[0], P[1], P[2], P[3], at.x);
- }
- const float epsilon = 0.001;
- void main(void)
- {
- vec4 p1 = evaluate_patch(gl_TessCoord.xy);
- vec4 p2 = evaluate_patch(gl_TessCoord.xy + vec2(0.0, epsilon));
- vec4 p3 = evaluate_patch(gl_TessCoord.xy + vec2(epsilon, 0.0));
- vec3 v1 = normalize(p2.xyz - p1.xyz);
- vec3 v2 = normalize(p3.xyz - p1.xyz);
- tes_out.N = cross(v1, v2);
- gl_Position = proj_matrix * p1;
- }
依据图 14 中的 16 个控制点,以及曲面细分结合,可以进一步感受曲面细分的过程。

总结
通过这篇笔记,我们初步了解了曲面细分着色器的概念。同时了解了如何在曲面细分着色器之间传递数据,以及曲面细分阶段各个参数的含义。