笔者介绍:姜雪伟,IT公司技术合伙人,IT高级讲师,CSDN社区专家,特邀编辑,畅销书作者,国家专利发明人;已出版书籍:《手把手教你架构3D游戏引擎》电子工业出版社和《Unity3D实战核心技术详解》电子工业出版社等。
CSDN视频网址:http://edu.csdn.net/lecturer/144
今天我们将讨论一种可以检测到3D物体的轮廓方式。 为了使事情变得更清楚,我指的是一个3D对象的轮廓,当光从任意方向落在它上面时。 移动光源可能会相应地改变轮廓。 这完全不同于在图像空间中的轮廓检测,其处理在2D图像中找到对象的边界(其通常不依赖于光源的位置)。 虽然轮廓检测的主题本身可能是有趣的,但对我们来说,其主要目标是实施Stencil Shadow Volume(在上篇博客中介绍)的第一步。 这是一种渲染阴影的技术,在处理点光时尤其有用。 我们将在上一篇博文中研究过此技术。
以下图像演示了我们想要检测的轮廓:
在上面的图像中,轮廓是由光线照射。。。。。。
让我们现在转向更传统的3D语言,模型基本上由三角形组成,因此轮廓必须由三角形边缘创建。我们如何决定边缘是否是剪影的一部分?诀窍是基于漫射光模型,根据该模型,光强度是基于三角形法线和光矢量之间的点积,如果三角形远离光源,该点积运算的结果将小于或等于零。在这种情况下,光线根本不影响三角形。为了确定三角形边缘是否是剪影的一部分,我们需要找到共享相同边缘的相邻三角形,并计算原始三角形及其相邻的光线方向和法线之间的点积,如果一个三角形面向光,但其相邻的边则不会将边缘视为剪影边缘。
下图显示了2D对象简单轮廓产生:
红色箭头表示射中法线为1,2和3的三个边缘的光线(这些法线之间的点积与反向光矢量显然大于零), 法线为4,5和6的边缘面向远离光(这里相同的点积小于或等于零), 两个蓝色圆圈标记对象的轮廓,原因是边缘1面向光,但其相邻边缘6不是, 因此他们之间的点是一个轮廓, 另一个剪影点也是如此。
你可以看到,找到轮廓的算法非常简单。 然而,它要求我们知道每个三角形的三个相邻, 这被称为三角形的邻接。 不幸的是,Assimp不支持自动相邻计算,所以我们需要自己实现这样一个算法, 在编码部分,我们将回顾一个能够满足我们需求的简单算法。
轮廓算法本身的最佳选择是什么? 请记住,我们需要在光矢量和三角正态之间做一个点积,以及三个相邻三角形的法线。 这需要我们访问整个模型信息, 所以VS还不够。 看起来GS更合适,因为它允许访问所有顶点。 幸运的是,OpenGL的设计人员已经给予了很多想法,并创建了一种称为“邻接三角形”的拓扑类型。 如果您提供具有邻接信息的顶点缓冲区,它将正确加载它,并为每个三角形提供GS顶点,而不是三个顶点。 附加的三个顶点属于相邻的三角形,不与当前三角形共享。 以下图像应该使这更清楚:
上图中的红色顶点属于原始三角形,蓝色的顶点是相邻的顶点(忽略边缘e1-e6,它们在代码部分稍后引用), 当我们以上述格式提供顶点缓冲器时,对每个顶点(相邻和不相邻)执行VS,并且在包含三角形及其相邻顶点的六个顶点的组中执行GS(如果存在)。 当GS存在时,由开发人员提供输出拓扑,但是如果没有GS,则光栅化知道如何处理这种方案,并且仅光栅化实际的三角形(忽略相邻的三角形)。 如果在使用英特尔HD 3000的Macbook上产生了错误,或者如果遇到类似的问题,只需使用通过GS的通行证,或者更改拓扑类型。
请注意,顶点缓冲区中的相邻顶点与常规顶点具有相同的格式和属性,使它们相邻的只是它们在每组六个顶点内的相对位置。 在三角形连续的情况下,根据当前的三角形,相同的顶点有时会是规则的,有时是相邻的, 由于节省了顶点缓冲区中的空间,这使索引绘制更具吸引力。
源代码如下所示:
void Mesh::FindAdjacencies(const aiMesh* paiMesh, vector& Indices) { for (uint i = 0 ; i < paiMesh->mNumFaces ; i++) { const aiFace& face = paiMesh->mFaces[i]; Face Unique; // If a position vector is duplicated in the VB we fetch the // index of the first occurrence. for (uint j = 0 ; j < 3 ; j++) { uint Index = face.mIndices[j]; aiVector3D& v = paiMesh->mVertices[Index]; if (m_posMap.find(v) == m_posMap.end()) { m_posMap[v] = Index; } else { Index = m_posMap[v]; } Unique.Indices[j] = Index; } m_uniqueFaces.push_back(Unique); Edge e1(Unique.Indices[0], Unique.Indices[1]); Edge e2(Unique.Indices[1], Unique.Indices[2]); Edge e3(Unique.Indices[2], Unique.Indices[0]); m_indexMap[e1].AddNeigbor(i); m_indexMap[e2].AddNeigbor(i); m_indexMap[e3].AddNeigbor(i); }
大多数邻接逻辑都包含在上述函数和几个辅助结构中, 该算法由两个阶段组成。 在第一阶段,我们在每个边缘和共享它的两个三角形之间创建一个地图。 这在上面的for循环中发生,在这个循环的前半部分,我们生成每个顶点位置和引用它的第一个索引之间的映射。 不同索引可能指向具有相同位置的顶点的原因是有时其他属性会强制Assimp将相同的顶点分割成两个顶点。 例如 ,相同的顶点对于共享它的两个相邻三角形可能具有不同的纹理属性, 这对我们的邻接算法造成了一个问题,我们更喜欢每个顶点只显示一次。 因此,我们创建一个位置和第一个索引之间的映射,并且从现在开始只使用这个索引。
代码如下所示:
for (uint i = 0 ; i < paiMesh->mNumFaces ; i++) { const Face& face = m_uniqueFaces[i]; for (uint j = 0 ; j < 3 ; j++) { Edge e(face.Indices[j], face.Indices[(j + 1) % 3]); assert(m_indexMap.find(e) != m_indexMap.end()); Neighbors n = m_indexMap[e]; uint OtherTri = n.GetOther(i); assert(OtherTri != -1) const Face& OtherFace = m_uniqueFaces[OtherTri]; uint OppositeIndex = OtherFace.GetOppositeIndex(e); Indices.push_back(face.Indices[j]); Indices.push_back(OppositeIndex); } } }
在第二阶段,我们用索引矢量填充每个匹配三角形列表的拓扑结构的六个顶点以及我们之前看到的邻接关系。 我们在第一阶段创建的地图在这里帮助我们,因为对于三角形中的每个边缘,很容易找到共享它的相邻三角形,然后找到与该边缘相反的三角形中的顶点。 循环中的最后两行将折叠缓冲区的内容从当前三角形的顶点和与当前三角形边缘相反的相邻三角形的顶点进行交替。
实现轮廓监测的Shader代码如下所示:
(silhouette.vs) #version 330 layout (location = 0) in vec3 Position; layout (location = 1) in vec2 TexCoord; layout (location = 2) in vec3 Normal; out vec3 WorldPos0; uniform mat4 gWVP; uniform mat4 gWorld; void main() { vec4 PosL = vec4(Position, 1.0); gl_Position = gWVP * PosL; WorldPos0 = (gWorld * PosL).xyz; }
我们只需要使用WVP矩阵将位置转换为裁剪空间,并向GS提供世界空间中的顶点(因为轮廓算法在世界空间中发生)。
(silhouette.gs) #version 330 layout (triangles_adjacency) in; layout (line_strip, max_vertices = 6) out; in vec3 WorldPos0[]; void EmitLine(int StartIndex, int EndIndex) { gl_Position = gl_in[StartIndex].gl_Position; EmitVertex(); gl_Position = gl_in[EndIndex].gl_Position; EmitVertex(); EndPrimitive(); } uniform vec3 gLightPos; void main() { vec3 e1 = WorldPos0[2] - WorldPos0[0]; vec3 e2 = WorldPos0[4] - WorldPos0[0]; vec3 e3 = WorldPos0[1] - WorldPos0[0]; vec3 e4 = WorldPos0[3] - WorldPos0[2]; vec3 e5 = WorldPos0[4] - WorldPos0[2]; vec3 e6 = WorldPos0[5] - WorldPos0[0]; vec3 Normal = cross(e1,e2); vec3 LightDir = gLightPos - WorldPos0[0]; if (dot(Normal, LightDir) > 0.00001) { Normal = cross(e3,e1); if (dot(Normal, LightDir) <= 0) { EmitLine(0, 2); } Normal = cross(e4,e5); LightDir = gLightPos - WorldPos0[2]; if (dot(Normal, LightDir) <=0) { EmitLine(2, 4); } Normal = cross(e2,e6); LightDir = gLightPos - WorldPos0[4]; if (dot(Normal, LightDir) <= 0) { EmitLine(4, 0); } } }
所有轮廓逻辑都包含在GS中,当使用具有相邻拓扑的三角形列表时,GS接收六个顶点的数组。我们首先计算一些选定的边,这将有助于我们计算当前三角形法线以及三个相邻的三角形。使用上图来了解如何将e1-e6映射到实际边。然后我们通过计算其法线和光方向(光向量朝向光)之间的点积来检查三角形是否面向光。如果点积的结果为正,则答案为是(由于浮点不准确,我们使用小的epsilon),如果三角形不面对光,那么这是光就对它不起作用,但是如果它是光面对的,我们在光矢量和三个相邻三角形中的每一个之间进行相同的点积运算。如果我们碰到一个不面向光的相邻三角形,我们称之为EmitLine()函数(不出意料的)发出三角形(面向光)和它的相邻(没有)之间的共享边, FS只是把这边缘画成红色。
实现的主要函数如下所示:
void RenderScene() { // Render the object as-is m_LightingTech.Enable(); Pipeline p; p.SetPerspectiveProj(m_persProjInfo); p.SetCamera(m_pGameCamera->GetPos(), m_pGameCamera->GetTarget(), m_pGameCamera->GetUp()); p.WorldPos(m_boxPos); m_LightingTech.SetWorldMatrix(p.GetWorldTrans()); m_LightingTech.SetWVP(p.GetWVPTrans()); m_mesh.Render(); // Render the object‘s silhouette m_silhouetteTech.Enable(); m_silhouetteTech.SetWorldMatrix(p.GetWorldTrans()); m_silhouetteTech.SetWVP(p.GetWVPTrans()); m_silhouetteTech.SetLightPos(Vector3f(0.0f, 10.0f, 0.0f)); glLineWidth(5.0f); m_mesh.Render(); }
这就是我们如何使用轮廓技术,相同的对象被渲染两次。 首先用标准的照明着色器, 然后与轮廓着色器。 请注意,如何使用glLightWidth()函数来使轮廓更粗大,从而更加明显。
如果您使用上述代码创建演示,您可能会注意到轮廓线周围的轻微变化。 原因是第二个渲染生成与原始网格边缘大致相同深度的线。 这导致一种被称为Z作为轮廓的像素现象,并且原始网格以不一致的方式彼此覆盖(再次,由于浮点精度)。 为了解决这个问题,我们调用glDepthFunc(GL_LEQUAL)来放宽深度测试。 这意味着如果第二个像素呈现在具有相同深度的先前像素的顶部,则最后一个像素总是优先。