问题
当绘制自定义的结构时,你会发现光照不正确。
这是因为你没有指定正确的法线向量,显卡要求每个顶点都有法线信息,这样它才可以决定每个三角形获得多少光照,详细信息可见第六章。
为每个顶点计算法线向量看起来很复杂,因为大多数顶点被多个三角形共享。
解决方案
如果每个顶点只被一个三角形使用,你只需找到三角形的法线向量(换句话说,这个向量垂直于三角形)并将这个向量作为三个顶点的法线向量。
但是在一个结构中,所有顶点被几个三角形共享。要获取平滑的效果,每个顶点需要存储周围三角形所有法线的平均值。
工作原理
使用下列伪代码,你可以找到每个顶点正确的法线:
- 对于结构中的每个顶点,找到使用顶点的三角形。
- 计算这些三角形的法线向量。
- 求所有法线向量的平均值。
- 将这个平均法线存储到顶点中。
求平均值的过程是必须的,因为你总是要归一化存储在顶点中的法线向量(换句话说,让长度变为1)。
注意:因为在vertex和pixel shader中要使用法线向量计算光线因子,所以要让法线向量长度变为1。当光线因子只由入射光和法线之间的夹角决定时,一个较大的法线向量会导致光线因子变大,具体解释可见第六章。
你可以将上述步骤变为具体代码,但如果你交换步骤1和2,会变得更容易:
- 对于结构中的每个三角形,计算法线向量。
- 将这个向量添加到三角形的三个顶点的法线中。对所有三角形进行这个操作后,执行以下操作:
- 归一化结构中的每个顶点的法线向量。
计算三角形的法线:叉乘的定义
在计算前,你需要知道什么是法线。简单的说,法线是垂直于三角形的方向,这意味着三角形上的任一点法线都是相同的。因为法线垂直于三角形平面,所以它也垂直于三角形的任何一个顶点。
那么如何计算一个三角形的法线向量呢?你可以使用叉乘,因为两个向量的叉乘返回垂直于两个向量决定的平面的向量。
你可以取三角形的两条边,通过叉乘获取垂直这个三角形的向量,如图5-13所示。这个法线的长度基于两条边的长度和夹角,所以需要将法线向量归一化。
图5-13 获取三角形的法线向量
注意:Vector3. Cross (Vec1, Vec2)和Vector3. Cross (Vec2,Vec1)计算结果是不同的。这两个结果长度相同但方向相反。这是因为一个平面有两个垂直方向:一个指向纸外,一个指向纸内。
GenerateNormalsForTriangleList方法
当定义一个大对象时,你通常希望使用索引定义结构,因为这是顶点被多个三角形共享的唯一方式,这让你可以平滑光照(见教程6-2)。所以这个教程基于结构中的顶点数组和索引数组进行计算:
Private VertexPositionNormalTexture[] GenerateNormalsForTriangleList(VertexPositionNormalTexture[] vertices, int[] indices) { }
这个方法接受一个不包含法线数据顶点数组,然后将正确的法线信息存储在每个顶点中并返回这个数组。根据索引信息,这个方法可以判断哪些顶点构成了三角形。但是,根据使用的是TriangleList还是TriangleStrip,索引数组的内容会不同,所以针对两者情况代码会有一些区别。
基于TriangleList计算法线
如果顶点已经包含法线数据,首先要将它们变为0:
for (int i = 0; i < vertices.Length; i++) vertices[i].Normal = new Vector3(0, 0, 0);
然后,如前面的伪代码所示,你要遍历所有三角形并计算它们的法线。在TriangleList中,每个三角形由三个连续的索引定义。这意味着三角形的数量为Length/3。下面是遍历定义在索引数组中的每个三角形的循环代码:
for (int i = 0; i < indices.Length/3; i++) { Vector3 firstVec = vertices[indices[i*3 + 1]].Position - vertices[indices[i*3]].Position; Vector3 secondVec = vertices[indices[i*3 + 2]].Position- vertices[indices[i*3]].Position; Vector3 normal = Vector3.Cross(secondVec, firstVec); normal.Normalize(); vertices[indices[i*3]].Normal += normal; vertices[indices[i*3 + 1]].Normal += normal; vertices[indices[i*3 + 2]].Normal += normal; }
对每个三角形,你定义了两个如图5-13所示的向量。点P0和P1之间的向量可以通过将P1减P0获得,这是第一行代码。第二行代码计算从P0指向P2的向量。
然后,通过将两者叉乘获取垂直于这两个向量的向量,别忘了归一化结果让它的长度变为1。
注意:根据你定义索引的方式,你需要使用Vector3. Cross (secondVec, firstVec); 代替。前面已经提到过,这会获取一个相反方向的矢量。如果沿顺时针方向定义顶点(见教程5-6), 代码会工作正常。
知道了三角形的法线,只需简单地将它添加到每个顶点中。当for循环完成后,每个顶点就存储了三角形的所有法线的和。然后要通过归一化将这些大向量的长度变为1。
for (int i = 0; i < vertices.Length; i++) vertices[i].Normal.Normalize(); return vertices;
把所有法线存储到顶点数组后,将这个数组返回到调用代码。
基于TriangleStrip计算法线
对TriangleStrip来说情况有点不同,因为创建索引数组中的每个索引都是基于它和前两个索引创建一个三角形的:
for (int i = 2; i < indices.Length; i++) { Vector3 firstVec = vertices[indices[i - 1]].Position-vertices[indices[i]].Position; Vector3 secondVec = vertices[indices[i - 2]].Position-vertices[indices[i]].Position; Vector3 normal = Vector3.Cross(firstVec, secondVec); normal.Normalize(); }
从第三个索引开始,你遇到的每个索引基于索引i, i-1和i-2创建了一个三角形。前面的代码遍历了由索引数组定义的所有三角形并创建了两个对应三角形两边的向量。
但是,你以TriangleStrip方式定义索引时,每个三角形后会自动反转旋转顺序(见教程5-1的注意事项)。结果是,firstVec和secondVec会改变位置,在Cross方法中改变firstVec和secondVec的位置会达到同样效果,每个三角形后都会反转法线的方向。
你无法改变这个反转,但可以解决这个问题。只需建立一个Boolean变量,每个三角形后就反转这个值。如果这个值为true,你就改变法线的方向:
bool swappedWinding = false; for (int i = 2; i < indices.Length; i++) { Vector3 firstVec = vertices[indices[i - 1]].Position - vertices[indices[i]].Position; Vector3 secondVec = vertices[indices[i - 2]].Position -vertices[indices[i]].Position; Vector3 normal = Vector3.Cross(firstVec, secondVec); normal.Normalize(); if (swappedWinding) normal *= -1; vertices[indices[i]].Normal += normal; vertices[indices[i - 1]].Normal += normal; vertices[indices[i - 2]].Normal += normal; swappedWinding = !swappedWinding; }
代码的其他部分与前面类似,别忘了代码最前面的将初始法线复位到0和最后的归一化法线的代码。
使方法Fail-Safe
如果firstVec 和secondVec向量方向相同,Vector3 . Cross方法会发生错误。这种情况下三角形会变成一条线,被称之为ghost三角形(见教程5-8的例子)。
这种情况下,Vector3 . Cross会返回包含的三个NaN值的Vector3。如果发生这种情况,那么就不要将这个向量添加到顶点,否则会报错:
if (!float.IsNaN(normal.X)) { vertices[indices[i]].Normal += normal; vertices[indices[i - 1]].Normal += normal; vertices[indices[i - 2]].Normal += normal; }
从顶点缓冲和索引缓冲开始
前面的代码从包含顶点和索引的两个数组开始。如果你已经将它们存储在显存的缓冲中(见教程5-4)并保存了一个本地复制,你需要将这些数据取回来。下面是方法:
int numberOfVertices = myVertexBuffer.SizeInBytes / VertexPositionNormalTexture.SizeInBytes; VertexPositionNormalTexture[] vertices = new VertexPositionNormalTexture[numberOfVertices]; myVertexBuffer.GetData(vertices); int numberOfIndices = myIndexBuffer.SizeInBytes / 4; int[] indices = new int[numberOfIndices]; myIndexBuffer.GetData(indices);
你通过查看顶点缓冲占据多少个字节获取缓冲中的顶点数量,因为你知道一个顶点占据多数字节,所以可以知道在顶点缓冲中有多少个顶点。
同样的方法可以找到索引缓冲中的索引数量,因为你知道一个索引时4个字节。
注意:如教程5-4中的解释,并不推荐在顶点缓冲或索引缓冲上使用GetData。而且如果你使用的是BufferUsage. WriteOnly标志创建缓冲,编译器会对GetData方法报错。
代码
下面的代码将法线数据存储到一个顶点数组中,而且索引是以TriangleStrip方式绘制三角形的:
private VertexPositionNormalTexture[] GenerateNormalsForTriangleStrip(VertexPositionNormalTexture[] vertices, int[] indices) { for (int i = 0; i < vertices.Length; i++) vertices[i].Normal = new Vector3(0, 0, 0); bool swappedWinding = false; for (int i = 2; i < indices.Length; i++) { Vector3 firstVec = vertices[indices[i - 1]].Position- vertices[indices[i]].Position; Vector3 secondVec = vertices[indices[i - 2]].Position - vertices[indices[i]].Position; Vector3 normal = Vector3.Cross(firstVec, secondVec); normal.Normalize(); if (swappedWinding) normal *= -1; if (!float.IsNaN(normal.X)) { vertices[indices[i]].Normal += normal; vertices[indices[i - 1]].Normal += normal; vertices[indices[i - 2]].Normal += normal; } swappedWinding = !swappedWinding; } for (int i = 0; i < vertices.Length; i++) vertices[i].Normal.Normalize(); return vertices; }