一次简单的网格渲染

在 3D 计算机图形学中,可采用各种建模技术方案。如考察某一球体对象,可使用球体方程 \((x - Cx)^2 + (y - Cy)^2 + (z - Cz)^2 = r^2\) 这种基于隐式函数 \(f(x,y,z) = 0\) 的方式来表示隐式表面,也可根据拓扑实体(如顶点)采用显式方式表达球体对象,比如常用的多边形网格。

《计算机图形学-基于3D图形开发技术》

对于建模人员来说使用图形软件即可创建基于多边形网格的 3D 对象,并且 GPU 对多边形网格进行了优化处理。因此在大多数时候,我们都是用多边形网格表现 3D 对象。

下面我们就来看看网格是如何从构建成一个 3D 对象,到最终渲染成一副多彩的二维图像的。

从 Unity 中的 Mesh 说起

网格能表现 3D 对象,那么构成网格又需要哪些信息了? Unity 中,Mesh 类用于创建或者修改网格,它包含了许多的成员变量:

  • vertices 保存了顶点信息

  • triangles 所有的三角形顶点的索引(三个数据构成一个三角形)

  • normals 顶点的法线信息

  • tangents 顶点的切线信息

  • uv - uv8 8 组纹理坐标

  • ...

这里只列举了常用的一些需要的数据,还有更多的变量,读者可自行查阅文档

在这里,我们先创建一个 Mesh

1
Mesh _mesh = new Mesh();

MeshFilter 和 MeshRender

在 Unity 中,创建的 Mesh 要能够表现在你面前需要 MeshFilter 类的帮助。 MeshFilter 很简单,主要就是一个中间层来让你在程序上动态使用 Mesh。它有两个成员变量,meshsharedMeshmesh 就是这个类实例化自身独有的一个 Mesh,而 sharedMesh 顾名思义就是共享的一个 Mesh 实例,如果对这个共享的实例修改,那么可能会影响到其它也共享使用这个 Mesh 实例的 MeshFilterMeshFilter 将网格传递给 MeshRender 渲染。

除了 MeshFilter,渲染网格还需要一个类 MeshRender,它主要是负责渲染相关的设置。包括很多属性:

  • materials 渲染所有的材质(可以多个)

  • receiveShadows 是否接受阴影

  • shadowCastingMode 阴影投射模式

  • ...

诸如上面还有很多其它属性,其中最重要的就是 materials,如果不设置好渲染材质,那么 GPU 将不知道将要如何渲染你的网格。

接下来我们开始进行这次简单的网格渲染吧!

创建一个 GameObject,并给它添加上 MeshFilterMeshRenderMeshRender 需要设置好材质,并将我们最初创建的 Mesh 交给 MeshFilter:

1
GetComponent<MeshFilter>().mesh = _mesh;

接下来再回到我们的 Mesh,这里具体说说 verticestrianglesuv

vertices

vertices 是顶点数据(通常我们所说模型的顶点们就是它)存放的地方,也称为顶点缓冲区。通常不同的多边形网格连接在一起可能会共享一些顶点数据,如果采用每个多边形单独存储其顶点数据的方式,那么顶点缓冲区就会包含冗余数据。因此通常都会有一个独立索引缓冲区(triangles)来索引每个多边形网格的顶点,从而使得顶点缓冲区更加紧凑(不存在重复顶点数据)。

这里的顶点缓冲区定义,也可以将 normalstangentsuv - uv8 等包含进来,因为他们也是属于顶点的一部分,索引缓冲区同样对这些数据有效。

vertices 中的顶点数据决定了多边形网格可能会处的位置。接下来给 Mesh 的顶点缓冲区赋予一些顶点数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// mesh size
int _xSize = 6, _ySize = 3;
// mesh vertices size
Vector3[] _vector3s = new Vector3[_xSize * _ySize];
for (int j = 0; j < _ySize; j++)
{
for (int i = 0; i < _xSize; i++)
{
_vector3s[j * _xSize + i] = new Vector3(i, j, 0);
}
}
// assign to vertex buffer
_mesh.vertices = _vector3s;

通过上面的代码,我们大致得到了空间中这样一些点:

\[ \left\{ \begin{matrix} (0,2,0) & (1,2,0) & (2,2,0) & (3,2,0) & (4,2,0) & (5,2,0) \\ (0,1,0) & (1,1,0) & (2,1,0) & (3,1,0) & (4,1,0) & (5,1,0) \\ (0,0,0) & (1,0,0) & (2,0,0) & (3,0,0) & (4,0,0) & (5,0,0) \end{matrix} \right\} \]

vertices 中存有上面这些顶点数据,依次从左到右,从下至上,索引如下:

\[ \left\{ \begin{matrix} 12 & 13 & 14 & 15 & 16 & 17 \\ 6 & 7 & 8 & 9 & 10 & 11 \\ 0 & 1 & 2 & 3 & 4 & 5 \end{matrix} \right\} \]

点可以连接起来组成多边形网格或线段。OpenGL 支持具有任意数量顶点的多边形,Direct3D 仅支持三角形网格,本文中也使用三角形网格。

triangles

triangles (索引缓冲区)包含了三角形网格的顶点数据的索引(三个数据代表一个三角形)。三个索引组成一个三角形,顺序可能有顺时针(CW)或逆时针(CCW)两种情况。

首先我们尝试使用逆时针顺序(CCW)索引来组成一个三角形:

1
2
3
4
5
6
7
int[] triangles = new int[3];
triangles[0] = 0;
triangles[1] = 1;
triangles[2] = 6;
// assign to indices buffer
_mesh.triangles = triangles;

这种情况下运行看不到渲染出来的三角形面。为什么了?

在 Unity 中使用左手坐标系(LHS,X朝右Y朝上Z朝里),因此如果索引是逆时针构成三角形网格,那么根据左手定则三角面法线(法线方向指定正面)朝里(Z 轴正方向,背向摄像机),在 Shader 默认 Cull Back 情况下(背面将被剔除不渲染),摄像机拍摄的三角面(背面)将不会被渲染,因此看不到三角面。

在 Scene 编辑器中把场景转到 Z 轴正方向可以看到渲染出来的三角形网格(因为这一面才是正面)。解决上面问题有两个办法:

  • 将索引修改为顺时针构成三角形网格,根据左手定则三角面法线将朝外(Z轴负方向),正面将朝向摄像机,因此在 Shader 默认 Cull Back 情况下就能看到渲染的三角形。

  • 将 Shader Cull Back 改成剔除正面 Cull Front 或者不剔除 Cull Off 即可。

我们使用顺时针索引顺序来作修改:

1
2
3
4
5
triangles[0] = 0;
triangles[1] = 6;
triangles[2] = 1;
_mesh.triangles = triangles;

现在能看到渲染出的三角形,如下图:

mesh_rendering_2.jpeg

把之前输入的顶点的全部按照顺时针顺序索引渲染三角形,用三角形网格组成一个平面,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
triangles = new int[_xSize * _ySize * 6];
for (int i = 0, m = 0; i < _ySize - 1; i++)
{
for (int j = 0; j < _xSize - 1; j++, m += 6)
{
triangles[m] = i * _xSize + j;
triangles[m + 1] = triangles[m] + _xSize;
triangles[m + 2] = i * _xSize + j + 1;
triangles[m + 3] = triangles[m + 1];
triangles[m + 4] = triangles[m + 1] + 1;
triangles[m + 5] = triangles[m + 2];
}
}
_mesh.triangles = triangles;
mesh_rendering_3.jpeg

上面我们定义了 triangles 的大小为 _xSize * _ySize * 6,为什么是这么多了。想象一下三角形组成的平面中,一个顶点最多可能被 6 个三角形共享,因此这个顶点会被索引 6 次,我们的代码中顶点的数量是 _xSize * _ySize,因此索引缓冲区的大小就是 _xSize * _ySize * 6

uv

uv(第一组纹理坐标)包含了模型顶点上面对应的纹理采样坐标。它的大小应该是同 vertices 一样大,从另一个角度来看,纹理坐标其实也是属于顶点数据的一部分,所以也可以看做是属于顶点缓冲区的。

当需要将一个 Texture 贴到我们的模型(网格组成)表面,就应当给模型每个顶点指定纹理采样坐标。纹理的 UV 值通常限定在了 [0,1] 之间,因此顶点的 uv 也应当属于 [0,1],不然纹理就可能采样错误(纹理不同的设置采样结果也不一样)。

下面为 Mesh 设置上第一组纹理坐标:

1
2
3
4
5
6
7
8
9
10
11
Vector2[] uvs = new Vector2[_vector3s.Length];
for (int i = 0; i < _ySize; i++)
{
for (int j = 0; j < _xSize; j++)
{
uvs[i * _xSize + j] = new Vector2((float)j / (_xSize - 1), (float)i / (_ySize - 1));
}
}
// assign to uv
_mesh.uv = uvs;

纹理坐标设置了,最后就需要对理进行采样。为了弄清除纹理坐标是如何被应用到采样过程中的,我们通过 Unity 创建一个无光照 Shader 并将其用到渲染网格的材质上,看看其采样纹理部分代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// ...
struct appdata
{
// ...
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
// ...
};
sampler2D _MainTex;
float4 _MainTex_ST;
v2f vert (appdata v)
{
v2f o;
// ...
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
return tex2D(_MainTex, i.uv);
}
// ...

顶点着色器 vert 中,需要从顶点数据中读取顶点的模型坐标(语义 POSITION)和第一组纹理坐标(语义 TEXCOORD0),这里读取的第一组纹理坐标也就是在程序中赋予 Meshuv。这里有个有意思的事情,如果只给 Meshuv 赋予了值,而 uv2 - uv8 没有赋值,那么在 Shader 中如果我读取第二组(TEXCOORD1)或其它组纹理坐标(TEXCOORDx)同样能得到正确的采样结果,说明 Unity 给 uv2 - uv8 赋予了 uv 的数据。

索引 0 1 2 3 4 5 6 7
Mesh uv uv2 uv3 uv4 uv5 uv6 uv7 uv8
Set x x x 4 组 x x 7 组 x
Shader TEX0 TEX1 TEX2 TEX3 TEX4 TEX5 TEX6 TEX7
Get 0 0 0 4 组 4 组 4 组 7 组 7 组

表格中 Set 栏的 x 表示未给设置纹理坐标;4 组 表示为当前 Meshuv4 设置上了自定义的第四组纹理坐标;Shader 栏中的 TEX0 是语义 TEXCOORD0 的缩写;Get 栏中的 0 表示 Shader 中获取的纹理坐标是 0,4 组 表示 TEXCOORD4 读取的是程序中自定义的第四组纹理坐标

所以当设置某组 uv 时,若其后面的 uv(x) 没有设置,那么后边的将会被设置一份和 uv 一样的纹理坐标值,直至遇到某个 uv(x) 又被设置了值为止,后面没有设置纹理坐标的又会拥有刚刚设置的这份新的值。都不设置为默认值 0。

再回到 Shader 中,在顶点着色器中通过 Unity 内置宏 TRANSFORM_TEX 对顶点输入的纹理坐标进行的缩放和平移,TRANSFORM_TEX 实现大致如下:

1
o.uv = v.uv * _MainTex_ST.xy + _MainTex_ST.zw;

其中 _MainTex_ST 里面的四个值就对应了材质中对 Texture 设置的 TilingOffset

最后纹理采样是在片元着色器中进行的,使用变换过的纹理坐标对目标纹理进行采样并将颜色返回:

1
fixed4 color = tex2D(_MainTex, i.uv);
mesh_rendering_4.jpeg

到这里,从创建网格到渲染呈现基本完成。当然真实的过程复杂的多,比如复杂网格通常是由美术建模得到的,并将顶点、三角形网格、纹理坐标甚至法线等数据事先预制在模型中。GPU 渲染通常也是复杂的工程,Shader 编写可能涉及到顶点变换、光照计算、透明度混合等等许多事项。并且本文主要是对网格渲染做一个大致的讲解。

参考