这一课中我们将介绍一种检测对象边缘的方式,为了方便介绍,我们这里所提到的 3D 对象的轮廓都是当从任意方向上照射到物体上的光线创建出来的。当光源发生移动时,物体的轮廓也会随之变化。这与在 2D 图片中检测图片中物体的边界完全不同(图片中的边缘检测与光源的位置无关)。虽然说这一课的主要目的是介绍边缘检测的,但是对于我来说这一课另一个重要目的是作为 模板阴影体 技术的第一个步骤,模板阴影体技术主要用于渲染点光源所产生的阴影,我们会在下一课学习这个技术(所以你也可以将这一课作为模板阴影体的第一部分)。
下面的图片展示饿了我们所想实现的边缘检测效果:
上面的图片中与光线接触的椭圆就是我们希望得到的轮廓。
现在让我们以更加传统的 3D 语言来描述这个问题,一个模型实际上也是由很多三角形组成的,所以轮廓也一定是由三角形的边缘组成的,但是我们如何判断某一条边就是轮廓的一部分呢?诀窍就是借助于漫反射光照模型。根据这个模型光照的强度是由三角形的法线向量与光照方向向量的点乘得到的,如果三角形的朝向光源的另一边,那么点乘的结果应该是小于或者等于 0,在这种情况下光源对三角面不会产生任何影响。为了判断三角形的某一条边是否为轮廓的一部分我们需要找到共享这一条边的另外一个三角形,并用光照方向向量与这两个三角形的法向量分别做点乘,如果其中一个三角形朝向光源而另一个三角形却不朝向光源(其中一个三角形的点乘结果大于 0, 而另外一个三角形小于或等于 0),那么这一条边就可以确定为轮廓的一部分。
为了简单起见我们先用一个 2D 对象来说明这个方法:
红色的箭头代表了照射到三条边上(在 3D 世界中这些边应该看做三角形,这里仅仅是为了说明问题用 2D 世界的一条边来代表 3D 世界的一个三角面)的光线,这三条边的法线方向分别为 1,2 和 3(这些法线光照方向的反方向的向量之间的点乘结果很明显都大于 0)。法线方向分别为 4,5 和 6 的边都没有朝向光源(进行同样的点乘操作这些边的结果都会小于小于或等于 0 )。被蓝色的圆圈标记的两个点就可以看做是这个对象的轮廓,因为边 1 是朝向光源的,但是它的相邻边 6 确不是,所以他们之间的点就是轮廓的一部分。对于其他轮廓点的判定也是一样的。如果某一条边朝向光源,但是与他相邻的边也是朝向光源的那么他们之间共享的点就不是轮廓的一部分(如边 1 和边 2 之间的点)。
正如你所看到的这样,找到轮廓的算法十分简单,但是它却需要我们能够访问每个三角形的三个相邻三角形,即邻接三角形。不幸的是 Assimp 不支持邻接三角形的计算,所以我们需要自己实现这一套算法。在编写代码的时候我们会介绍一个满足我们需求的简单算法。
但是我们在渲染管线的哪一个阶段来运行我们的轮廓检测算法呢?记住我们需要在光线方向向量和三角形法线方向以及这个三角形的三个邻接三角形法线方向之间进行点乘计算,这需要我们能够访问所有的图元信息,所以不应该在 VS 中进行计算。看起来 GS 阶段可能更加适合用于轮廓计算,因为在 GS 中允许访问图元的所有顶点,但是如何找到邻接三角形呢。幸运的是 OpenGL 的设计者早就考虑到了这一点,并且创建了一个名为三角邻接的拓扑类型。如果你在顶点缓存中存放了邻接信息,这些数据都会被正确的导入到 GS 中,并且对于每个三角形 GS 中都会保存 6 个顶点而不是 3 个顶点。多出的 3 个顶点分别属于与当前三角形邻接的三个三角形。并且这三个顶点不属于当前三角形。下面的图片应该能好的解释这个问题:
上图中的红色顶点是属于原来的三角形,蓝色的顶点输入这个三角形的邻接三角形(先不用关注边 e1 - e6 ,在编写代码的时候才会用到他们)。如果我们将上面图中所示格式的数据保存到顶点缓存中,VS 会对所有的顶点都执行一次(不论顶点属于邻接三角形还是原始三角形),GS (如果有的话)则会对上面这样的每一组顶点执行一次(包括原始三角形和邻接三角形的顶点)。如果存在 GS 开发者可以输出一个拓扑结构,但是如果没有 GS 则光栅化阶段会直接忽略这些邻接数据。
需要注意的是顶点缓存中的所存放的顶点没有没有邻接顶点和普通顶点之分,决定一个顶点是否为邻接顶点仅仅是根据其在每一组顶点中的相对位置。例如在一个由连续的三角形组成的模型中,同一个顶点有时作为普通顶点有时也可看做是邻接顶点,这都是根据当前三角形来说的。这种方式同样也节约了顶点缓存的空间,使得索引绘制也更加具有吸引力。
(mesh.cpp:204) |
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); |
} |
(mesh.cpp:240) |
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); |
} |
} |
} |
Mesh 类中只添加了一些新的函数以及变量,我建议你可以和前面课程中的 Mesh 类比较一下以确保你掌握了这一课中所有的变化,还有一个明显的变化是我们在调用 glDrawElementsBaseVertex() 函数时并没有使用 GL_TRIANGLES 作为绘制的拓扑结构而是使用 GL_TRIANGLES_ADJACENCY。如果你忘记了这里,那么 OpenGL 向 GS 中传入的图元的大小就会出问题。
(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; |
} |
(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); |
} |
} |
} |
(tutorial39.cpp:183) |
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(); |
} |
如果你运行这一课中的 Demo,你可能会注意到轮廓线的周围有一些轻微的抖动,这是因为在第二遍绘制过程中我们绘制的轮廓线的深度值与原来模型中边缘的深度值几乎是一样的。这使得轮廓的像素和原始模型的像素之间的覆盖产生了冲突(同样是因为浮点数的精度问题)。为了解决这个问题,我们可以调用 glDepthFunc(GL_LEQUAL) 对深度测试过程进行设置,这样如果有新的像素绘制到当前像素上,在两者的深度值相同的情况下,新的像素会替换到之前的像素。