[OpenGL] 曲面细分特性实践

参考资料:

https://www.nvidia.cn/object/tessellation_cn.html

https://www.opengl.org/wiki/Tessellation

背景

        首先曲面细分顾名思义是将一个面片划分为更多面片,使得模型更加精细。此处我们讨论的曲面细分更偏向于运行时的细分生成,而不是在建模软件中的离线细分。

        这一过程是可以在CPU中完成的,但组织多个点的动态添加和删除,并且更新同步到着色器所需的vb中,这一过程比较消耗性能,并且实现起来也不能算是特别容易的事情。一个比较理想的做法应该是在GPU硬件中完成相关的操作,并且给用户提供一些可编程的接口。

        带着这一目的,DirectX 11引入了新特性——GPU曲面细分。之后,OpenGL4之后的版本也推出了曲面细分的相关硬件支持(OpenGL ES 3.2)。

        仅仅从模型精细度角度而言,高精度的模型带来的画质提升并不如一张优质的贴图带来的性价比高。我们在建模软件中辛辛苦苦地做了高模,烘焙贴图,又为了性能而把低精度的模型导入游戏,为的都是能够以更低的成本跑出质量尚可的画质。但是曲面细分却反其道而行之,把我们精心删减模型的面数又还了回来。这两者无论如何看起来都是矛盾的。

        以上问题是我在刚了解曲面细分时产生的一些困惑,关于这一问题的解答,以下仅仅是我的一些猜测:

       (1) 性能消耗。

        高模物体带来的一方面消耗在于增加了顶点着色器的负担,另一方面在于,我们需要在内存中维护大量顶点数据,并在drawcall时cpu传递给gpu大量顶点数据。但如果细分的过程是在GPU中完成的话,那我们就可以避免后者的消耗。

     (2) 动态性。

        为了提高模型精度,一个最为简单的方法是直接导入高精度模型。但有时候我们并不总是需要那么高的精度,比如远处的模型。GPU的曲面细分就能很方便地实现这一点。

       (3) 可能性。

        对于应用而言,有时候我们希望利用一些顶点的性质来做一些特别的效果。但是顶点数量却并不一定足够,比如一些平面可能只有四个顶点。而在曲面细分的支持下,我们就可以更方便地实现一些动态的顶点相关效果,比如制作顶点动画,应用贴图置换等功能。

概念引入

        在编写着色器时,我们比较熟悉的是顶点和片元着色器,这是整个渲染管线中留给我们可编程的模块。新引入的曲面细分特性是作为渲染管线顶点处理部分新的可编程模块出现的。也就是说,为了实现曲面细分相关效果,我们并非通过简单的glEnable之类的接口开启,而是需要编写额外的shader,不同于原来的顶点和片元着色器,是一种新类型的着色器。

         我们知道原有的渲染管线大致是按照如下顺序执行的:

        vertex -> fragment 

         新引入的两个可编程模块称为曲面细分控制着色器(Tessellation Control,TCS)和曲面细分计算着色器(Tessellation Evaluation Shader,TES),在渲染管线中的执行顺序为:

        vertex -> tessellation control -> tessellation evaluation -> fragment

        

 

        如果我们需要在着色器中使用in/out来传递数据,那么也应当依照以上顺序传递,如果只是想像原来一样从顶点传到片元,那么我们还要先把这些数据传递给几个中转站。

        其中,TCS用于定义顶点应该如何细分(主要可修改的是细分方式细分程度),TES用于对细分后的顶点做一些处理。实际的细分操作是在TCS后一个不可编程模块完成的(Tessellation primitive generation),TCS只起到控制作用。

        在引入了新的着色器后,顶点着色器的用处就不大了,但是它作为渲染管线的一部分依然是保留的。一般而言,我们只需要将顶点(未经过MVP矩阵处理)数据原封不动地传递到曲面细分着色器即可,而顶点着色器的工作被TES取代了,因为只有在这里做相关顶点处理,才能覆盖到新生成的细分顶点。

        虽然添加了新的渲染模块,但是这并不是强制的,我们可以在新版本的OpenGL中不绑定曲面细分着色器。

使用方式

        首先,我们需要给shaderprogram额外绑定TCS和TES着色器,绑定方式和顶点/片元类似:

GLuint tcShader = glCreateShader(GL_TESS_CONTROL_SHADER);
glShaderSource(tcShader, 1, tcStr, nullptr);
glCompileShader(tcShader);

GLuint teShader = glCreateShader(GL_TESS_EVALUATION_SHADER);
glShaderSource(teShader, 1, teStr, nullptr);
glCompileShader(teShader);

        特别需要注意的是,在开启曲面细分后,我们绘制的函数要传入GL_PATCHES,而不再是GL_TRIANGLES,否则我们将无法绘制出任何东西,如下所示:

glDrawElements(GL_PATCHES, 36, GL_UNSIGNED_SHORT, nullptr);
glDrawArrays(GL_PATCHES, 0, 3);

        此处的patch就是细分阶段绘制的基本类型,它是一堆顶点的集合,被称为控制点。我们可以定义每个patch由多少个顶点组成,最大值可为GL_MAX_PATCH_VERTICES(不会小于32),比如设定为3:

glPatchParameteri(GL_PATCH_VERTICES, 3);

        细分着色器

        完成了准备工作之后,开始进行着色器编写部分。

        ● 顶点着色器

        正如我们前面所提及的,在曲面细分的渲染管线中,由于新的顶点是在顶点着色器之后生成的,所以对顶点的统一处理也放到之后。所以,如果我们没有什么特别需求的话,直接把相关的数据原封不动地传给TCS就可以了。

#version 450 core

in vec4 a_position;
in vec3 a_normal;
in vec3 a_tangent;
in vec2 a_texcoord;

out vec2 v_texcoord;
out vec3 v_normal;

void main()
{
    gl_Position = a_position;

    v_normal = a_normal;
    v_texcoord = a_texcoord;
}

       ● 曲面细分控制着色器

        该着色器主要用于定义细分的一些参数,如细分程度等,可供发挥的空间并不大,格式较为固定。

        在这里,首先要做的一件事是把从顶点着色器拿到的数据分发给不同顶点,并直接传给TES(不经过任何插值)。因为我们将细分出更多的顶点,因此数据也将翻相应的倍数。更为具体地说,如果有m个patch,每个patch有n个顶点,那么TCS将被调用m * n 次。

        patch最终输出的顶点数如下定义(定义为3,那么patch就是一个三角形):

layout (vertices = 3) out;

        对于每个patch而言,它对应了多个顶点,因此数据存储在一个数组中。输入为gl_in, 输出为gl_out。数组下标可由内置变量gl_InvocationID取得;数组的大小由gl_MaxPatchVertices控制。虽然此处的数组大小为gl_MaxPatchVertices,但是我们在当前能够访问/写入的有效数据仅为当前下标的数据。

        内部和外部曲面细分的程度由 gl_TessLevelInner[2] 和 gl_TessLevelOuter[4] 控制,是描述最终的细分形状的主要变量。因为此处我使用的是vertices = 3的三角细分,因此此处也只关注了三角形的细分方式。

        对于三角形而言,我们只需考虑前1个内部细分,和前3个外部细分,也就是说,最终我们只需填充gl_TessLevelInner[0], gl_TessLevelOuter[0], gl_TessLevelOuter[1], gl_TessLevelOuter[2] 这几个位置的数值。

        要搞懂这几个参数的具体含义需要花一些时间,我建议对于只是想试用曲面细分着色器的萌新把这几个数值直接填成一样的就可以了,并且能够认识到这么一个事实:数值越大,细分程度越高。

        关于这几个参数的具体含义,可以在官网上看到对应的解释。以下是我从官网上偷的图,加上一些个人理解:

        TriangleLevels.png

         其中,IL-0 对应着gl_TessLevelInner[0]。

        OL-0, OL-1, OL-2分别对应着gl_TessLevelOuter[0], gl_TessLevelOuter[1], gl_TessLevelOuter[2]。

        Inner值描述的是内部的三角形同心环。当它的值为1时,意味着没有细分,这个值好像并没有什么意义。当它的值为2时,对应的同心环将退化为一个顶点;而当它为3、4时,划分的同心环效果如下:

  TriInnerOnlyTriCorr.png       TriInnerOnlyPointCorr.pngTriInnerOnlyPointCorr.png

        由此可见,当inner Tess 取值为偶数的时候,最内的三角形环将是一个顶点。

        Outer值描述的是三角形的三条边的细分程度,因此它有三个值。实际应用中三个一般都会填充成统一数值,生成的三角形会比较均匀。

     TriOuterAndNotris.png

        如上图,为Inner Tess取值为5,Outer Tess取值分别为4, 1, 6 时,所得到的顶点分布。

        最后要做的是把这些点连成三角形。实际上我觉得它具体怎么连,对于使用者而言,都不是很重要……但是inner值和outer值对应的划分过程,还是有必要了解一下的。

#version 450 core

in vec2 v_texcoord[gl_MaxPatchVertices];
in vec3 v_normal[gl_MaxPatchVertices];

out vec2 c_texcoord[gl_MaxPatchVertices];
out vec3 c_normal[gl_MaxPatchVertices];

layout (vertices = 3) out;

void main(void)
{
    gl_TessLevelInner[0] = 7;
    gl_TessLevelOuter[0] = 7;
    gl_TessLevelOuter[1] = 7;
    gl_TessLevelOuter[2] = 7;

    gl_out[gl_InvocationID].gl_Position = gl_in[gl_InvocationID].gl_Position;

    c_texcoord[gl_InvocationID] = v_texcoord[gl_InvocationID];
    c_normal[gl_InvocationID] = v_normal[gl_InvocationID];
}

       根据官方文档,数组大小是可以省略的,glsl会为其分配一个默认值:

out vec2 c_texcoord[];

        ● 曲面细分评估着色器

        我们可以把曲面细分评估着色器理解为经过了曲面细分后,应用于所有顶点的“顶点着色器”。在这一着色器中,我们可以完成原来本该在顶点着色器应该做的事情了。

        曲面细分评估着色器的输入来自曲面细分控制着色器(如果没有的话,则直接来自顶点着色器)。

        首先,依然需要定义一些基本的参数:

layout (triangles, equal_spacing, ccw) in;

        第一个参数用于定义patch的类型,它有如下的可选项:isolines(平行线)、 triangles(三角形)、 quads(四边形)。该参数是必须的。

        第二个参数用于控制细分顶点之间的距离,它有如下的可选项:equal_spacing(距离相等)、fractional_even_spacing(偶数个线段)、fractional_odd_spacing(奇数个线段)。可选,默认是equal_spacing。感觉没有什么特别需求用默认就可以了。

       关于这几个选项的解析,wiki上有一个比较生动的动图:

SubdivideEqual.gifequal_spacing

SubdivideOdd.gif fractional_even_spacing

SubdivideEven.gif fractional_odd_spacing

      第三个参数用于定义新生成顶点的环绕顺序,它有如下的可选项:ccw(顺时针), cw(逆时针)。它和普通顶点的环绕顺序是一样的,用右手四指围绕着这一顺序转动,拇指方向就是这些点构成的面的法线方向(或者说正面)。我们需要定义新的点按顺时针解析,还是逆时针解析。此参数可选,默认是ccw。

      由于我们之前提到,从TCS传来的数据,是不经过插值的,所以我们需要手动换算一下。

      glsl为我们提供了一个叫做gl_TessCoord的变量,它定义了顶点在三角形patch里的位置。我们新生成的顶点处于三角形内部(包括边上),它可以使用三角形的重心坐标来表达。如果重心坐标为(λ1,λ2,λ3),那么它对应的笛卡尔坐标为:

        

        其中,x1,x2,x3,y1,y2,y3是三角形三个顶点的笛卡尔坐标系。也就是说,顶点的笛卡尔坐标可由三角形的三个顶点按照重心坐标加权得到。

        这种坐标表达方式为我们计算最终的加权数值提供了方便,我们也仅需对不同分量按重心坐标进行加权,就能得到最终结果。具体的计算为:

  gl_TessCoord [ 0 ]  *  value [ 0 ] 
+ gl_TessCoord [ 1 ]  *  value [ 1 ] 
+ gl_TessCoord [ 2 ]  *  value [ 2 ]

        最终的示例代码如下:

#version 450 core
layout (triangles, equal_spacing, ccw) in;

uniform mat4 ModelMatrix;
uniform mat4 IT_ModelMatrix;
uniform mat4 ViewMatrix;
uniform mat4 ProjectMatrix;

in vec2 c_texcoord[gl_MaxPatchVertices];
in vec3 c_normal[gl_MaxPatchVertices];

out vec2 v_texcoord;
out vec3 v_normal;

vec3 interpolate3D(vec3 v0, vec3 v1, vec3 v2)
{
    return vec3(gl_TessCoord.x) * v0 + vec3(gl_TessCoord.y) * v1 + vec3(gl_TessCoord.z) * v2;
}

vec2 interpolate2D(vec2 v0, vec2 v1, vec2 v2)
{
    return vec2(gl_TessCoord.x) * v0 + vec2(gl_TessCoord.y) * v1 + vec2(gl_TessCoord.z) * v2;
}

void main(void)
{

    vec4 pos = gl_in[0].gl_Position * gl_TessCoord.x +
               gl_in[1].gl_Position * gl_TessCoord.y +
               gl_in[2].gl_Position * gl_TessCoord.z;

    vec3 normal = interpolate3D(c_normal[0],c_normal[1],c_normal[2]);
    v_texcoord = interpolate2D(c_texcoord[0],c_texcoord[1],c_texcoord[2]);

    mat3 M1 = mat3(IT_ModelMatrix[0].xyz, IT_ModelMatrix[1].xyz, IT_ModelMatrix[2].xyz);
    v_normal = normalize(M1 * normal);

    gl_Position = ProjectMatrix * (ViewMatrix * (ModelMatrix * pos));
}

 

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值