作者:i_dovelemon
来源:CSDN
日期:2018-05-19
主题:Radiosity Algorithm, Global Illumination, Barycentric Coordinate
早在Quake的时代,卡马克就首创了使用Surface Caching来实现预先烘焙的光照贴图(Light Map)效果。在这之后,光照贴图实现的GI效果慢慢的得到了游戏开发者和学术界的共同推进,慢慢的发展出了很多不同的算法。EA的DICE曾讲述了GI效果的前世今生,在这里可以了解到相关的技术。
接下来的几篇文章我将向大家讲述如何实现一个简单的光照贴图烘焙程序,用于烘焙出场景里面的GI效果。最终希望在此基础上,实现Valve在Source引擎中实现的Radiosity Normal Map的光照烘焙效果。
在实现Radiosity Normal Map之前,我们需要先实现一个Old School的光照烘焙程序,然后在此基础上进行改进。下面我们来看看如何实现一个最基本的Light Map Baker吧。(题图是本文实现的Baker所烘焙出来的效果)
在所有开始之前,我们先来了解下什么是光照贴图,以及我们为什么要烘焙光照贴图。
在大家初次学习光照的时候,所有的教程都会讲解如何实现一个平行光,点光源,聚光灯等等实时的光照效果。但是这些光照效果是直接光照(Direct Lighting),也就是只考虑了光源对物体本身的光照效果。实际上在现实世界里面,物体会反射光源的光,从而导致其他的物体被物体反射的光所照亮(Indirect Lighting)。这样就导致了影子看上去不是全黑的,一个颜色的墙壁会染上其他墙壁的颜色等等效果,见下图。
实现烘焙的算法有很多,比如Path Tracer, Photo Mapping还有Radiosity Algorithm等等。本文将要实现的方案是基于Radiosity Algorithm。
这里有一篇文章,很清晰的讲述了Radiosity Algorithm的机理。我不认为我能够讲解的比他的还要清楚明了,所以关于Radiosity Algorithm的解释,我就不在赘述了。当然,如果你和我一样相信他所讲的就是全部了,那么你依然无法根据那篇文章实现烘焙功能,至少效果是不太正确的。所以我在这里讲述一些它所没有涉及的部分。
大体上,基于Radiosity Algorithm的烘焙程序,是由如下的步骤组成的:
场景分割方案有多种不同的方式。我这里由于最终需要的结果是一张LightMap,所以就根据LightMap来分割场景。
正如前面我们讲述了那样,对于一个场景,我们使用一整张LightMap,来表示它的光照烘焙结果,而不是每一个物体一张光照贴图。这就导致了,场景中所有物体的UV,除了本身进行光照渲染时需要使用的访问Albedo,Normal等等的传统UV,还需要另外一套对应于光照贴图的UV坐标,也就是说整个场景的第二套UV坐标是统一在一个UV坐标系里面的。这样才能够统一的访问光照贴图,得到对应的光照结果。
根据辐射度算法一文的描述,一个LightPatch就对应了光照贴图里面的一个像素。而对于一个LightPatch,我们需要知道它所在场景的点的位置(Position)以及法线(Normal)。那么,对于光照贴图里面的任意一个像素,我们都能够知道它的UV坐标。而模型文件中保存了场景中所有三角形的顶点位置,法线和UV的信息。我们能否根据LightMap中像素的UV坐标,来得到对应的LightPatch所在点的位置及法线信息了。答案当然是可以的了。
在这里我们需要使用三角形的barycentric coordinate system来求出我们需要的信息。关于barycentric coordinate system的详细信息,可以看这里。
根据wiki的描述,我们能够得到一个三角形的如下关系式:
根据前面一小节的描述,我们已经能够根据光照贴图中像素的uv坐标,来得到对应的LightPatch所在的三角形,以及三角形中该点的位置。如下是完整的分割代码:
void PrepareLightPatch() {
memset(m_Patch, 0, sizeof(m_Patch));
// Calculate uv for every patch
for (int32_t h = 0; h < kLightMapHeight; h++) {
for (int32_t w = 0; w < kLightMapWidth; w++) {
m_Patch[h][w].uv = math::Vector((w + 0.5f) * 1.0f / kLightMapWidth, (kLightMapHeight - h - 0.5f) * 1.0f / kLightMapHeight, 0.0f);
}
}
// Collect all faces
struct Face {
struct {
math::Vector uv;
math::Vector pos;
math::Vector normal;
} vertex[3];
};
std::vector faces;
faces.clear();
scene::ModelEffectParam effectParam;
scene::ModelMaterialParam materialParam;
float* vertexBuf = NULL;
float* texBuf = NULL;
float* normalBuf = NULL;
int32_t faceNum = scene::ModelFile::ExtractModelData(kSceneModelFile, effectParam, materialParam, &vertexBuf, &texBuf, &normalBuf);
int32_t vertexOffset = 0, uvOffset = 0, normalOffset = 0;
for (int32_t i = 0; i < faceNum; i++) {
Face face;
for (int32_t j = 0; j < 3; j++) {
face.vertex[j].uv.x = texBuf[uvOffset++];
face.vertex[j].uv.y = texBuf[uvOffset++];
face.vertex[j].uv.z = 0.0f;
face.vertex[j].uv.w = 0.0f;
face.vertex[j].pos.x = vertexBuf[vertexOffset++];
face.vertex[j].pos.y = vertexBuf[vertexOffset++];
face.vertex[j].pos.z = vertexBuf[vertexOffset++];
face.vertex[j].pos.w = 1.0f;
face.vertex[j].normal.x = normalBuf[normalOffset++];
face.vertex[j].normal.y = normalBuf[normalOffset++];
face.vertex[j].normal.z = normalBuf[normalOffset++];
face.vertex[j].normal.w = 0.0f;
}
faces.push_back(face);
}
scene::ModelFile::RelaseBuf(&vertexBuf, &texBuf, &normalBuf);
// Calculate data for every patch
for (int32_t h = 0; h < kLightMapHeight; h++) {
for (int32_t w = 0; w < kLightMapWidth; w++) {
math::Vector uv = m_Patch[h][w].uv;
bool found = false;
for (int32_t i = 0; i < faceNum; i++) {
// Using triangle's barycentric coordinate system to calculate world position of light patch
// https://en.wikipedia.org/wiki/Barycentric_coordinate_system
float x = uv.x;
float y = uv.y;
float x1 = faces[i].vertex[0].uv.x;
float x2 = faces[i].vertex[1].uv.x;
float x3 = faces[i].vertex[2].uv.x;
float y1 = faces[i].vertex[0].uv.y;
float y2 = faces[i].vertex[1].uv.y;
float y3 = faces[i].vertex[2].uv.y;
float lambda0 = ((y2 - y3) * (x - x3) + (x3 - x2) * (y - y3)) / ((y2 - y3) * (x1 - x3) + (x3 - x2) * (y1 - y3));
if (lambda0 < 0.0f || lambda0 > 1.0f) continue;
float lambda1 = ((y3 - y1) * (x - x3) + (x1 - x3) * (y - y3)) / ((y2 - y3) * (x1 - x3) + (x3 - x2) * (y1 - y3));
if (lambda1 < 0.0f || lambda1 > 1.0f) continue;
float lambda2 = 1.0f - lambda0 - lambda1;
if (lambda2 < 0.0f || lambda2 > 1.0f) continue;
m_Patch[h][w].pos = faces[i].vertex[0].pos * lambda0 + faces[i].vertex[1].pos * lambda1 + faces[i].vertex[2].pos * lambda2;
m_Patch[h][w].normal = faces[i].vertex[0].normal;
m_Patch[h][w].valid = true;
m_ValidPatch.push_back(&m_Patch[h][w]);
found = true;
break;
}
if (found == false) {
m_Patch[h][w].valid = false;
}
}
}
}
这里有几个点需要注意。
这里的做法和辐射度一文中的做法大体上一样。
实际上这个权重贴图里面存放的是一个名为Form Factor(View Factor)的系数。我们定义两个LightPatch,一个LightPatch为Receiver,即接受其他所有LightPatch所发射过来的光照,另外一个LightPatch为Sender,发射它本身的光照。那么Sender的光照并不是完全等值的被Receiver给接受。根据这两个LightPatch法线,和观察向量以及两者之间的距离不同,会呈现不同的衰减状态,如下图所示:
#version 450
in vec2 vsTexCoord;
out vec3 oColor;
uniform int glb_Face;
uniform int glb_LightPatchWidth;
uniform int glb_LightPatchHeight;
float calc_perspective_correction_factor(vec2 uv) {
vec3 l = vec3((uv - vec2(0.5, 0.5)) * 2.0, 1.0);
l = normalize(l);
float vdotl = dot(vec3(0.0, 0.0, 1.0), l);
vdotl = max(0.0, vdotl);
return vdotl;
}
float calc_lambert_law_factor(vec2 uv, int face) {
vec3 n = vec3(0.0, 0.0, -1.0);
vec3 l = vec3(0.0, 0.0, 0.0);
vec2 p = (uv - vec2(0.5, 0.5)) * 2.0;
if (face == 0) {
l = vec3(1.0, -p.y, p.x);
} else if (face == 1) {
l = vec3(-1.0, -p.y, -p.x);
} else if (face == 2) {
l = vec3(p.x, 1.0, p.y);
} else if (face == 3) {
l = vec3(p.x, -1.0, -p.y);
} else if (face == 4) {
l = vec3(p.x, -p.y, -1.0);
}
l = normalize(l);
return max(0.0, dot(n, l));
}
float calc_area_factor(vec2 uv, int face, int w, int h) {
float deltaArea = 4.0 / (1.0 * w * h);
vec3 l = vec3(0.0, 0.0, 0.0);
vec2 p = (uv - vec2(0.5, 0.5)) * 2.0;
if (face == 0) {
l = vec3(1.0, -p.y, p.x);
} else if (face == 1) {
l = vec3(-1.0, -p.y, -p.x);
} else if (face == 2) {
l = vec3(p.x, 1.0, p.y);
} else if (face == 3) {
l = vec3(p.x, -1.0, -p.y);
} else if (face == 4) {
l = vec3(p.x, -p.y, -1.0);
}
float r = length(l);
return deltaArea / (3.1415926 * r * r);
}
void main() {
float vdotl = calc_perspective_correction_factor(vsTexCoord);
float ndotl = calc_lambert_law_factor(vsTexCoord, glb_Face);
float area = calc_area_factor(vsTexCoord, glb_Face, glb_LightPatchWidth, glb_LightPatchHeight);
oColor = vec3(vdotl * ndotl * area, vdotl * ndotl * area, vdotl * ndotl * area);
}
当然上面的结果也要和辐射度一文一样,需要单位化权重值得到最终的结果。
在辐射度一文中说,计算最后的光照贴图,需要将hemicube中所有像素相加,然后在除以像素总数。我在实际开发的过程中,发现如果这么做了,最后得到的结果就只是黑暗的一片。根据这篇文章的描述,Radiosity Algorithm有很多的变种。比如类似本文的这种,叫gathering-variant,是从Receiver的角度去收集(gathering)其他LightPatch所照射过来的光照。还有如Shooting-variant和Shooting and Sorting-variant,从从Sender的角度,去发射光线,然后更新每一个Receiver的光照。并且,作者对比了几种变种,在迭代100次之后的结果,如下图所示:
在不进行任何优化的话,渲染一张1024*1024的光照贴图迭代1次就要花费我将近7-8个小时。不能忍。所以,为了提高速度,加快对结果的审查,我简单的优化了下程序,基本能够忍受最终渲染的时长。
默认情况下,OpenGL是开启垂直同步的,也就是说SwapBuffer之类的函数,会强制等待屏幕的刷新,然后才能够继续进行下去。由于我的baker程序是把每一个LightPatch的一次hemicube分到了一帧里面去计算,这就导致了整体的时间由于垂直同步而拖慢了很多。所以我就修改了渲染库,添加了对垂直同步开启/关闭的支持。在这种情况下,我关闭了垂直同步的操作,速度立马飞起。原先由于强制刷新,每一个LightPatch,一次hemicube的计算需要16.6ms左右。关闭了垂直同步之后,只需要使用2-4ms左右。
关于垂直同步的信息,可以看这里。
由于每一次LightPatch的计算,都需要统计hemicube的所有像素之和。我优化前的做法是把贴图回读到CPU上,然后手动的依次叠加到一起。这个计算方法十分的低效。我们可以利用现代硬件mipmap的特性,为我们计算一张图的所有像素的平均值,然后根据这个平均值来计算整个像素图的所有像素之和。利用mipmap,产生的最低一级的mip,就是整张图的平均像素。
这个操作,可以看龚大在OpenGPU上的回答。
以上就是一个基本的基于Radiosity Algorithm的光照贴图的烘焙程序实现。其中有很多可以改进的地方。比如使用多线程加速LightPatch的光照计算,或者使用OpenCL/CUDA等GPU加速计算,更甚的使用辐射度一文中讲述的差值方法来加快计算。我这里为了概念的解释和了解,都没有进行这些尝试。等到最终我需要在实际场合下使用LightMap的时候,或许会专门设计一个LightMapBaker的GUI工具程序,用于快速产生光照贴图。谁知道了!!!
另外需要补充说明的是,以上的烘焙程序只是烘焙了场景的Diffuse部分。由于Diffuse模型,大部分采用的是Perfect Diffuse,即不管观察者处于什么视角,在同样的光照条件下,同一个表面的光照效果总是一致的。Radiosity Alogrithm也更加适合烘焙diffuse部分。虽然经过近似改进,也能够模拟Specular,但那不在本文范畴,就不再赘述。感兴趣的可以在文献[3]中找到答案。
完整的代码可以在我的Github上找到。
为了方便,很多处理我都使用了最粗暴的方法。比如我假设了场景中所有材质都是完全的白色材质,同时光源的处理也硬编码到shader文件中。
如果发现有什么错误或者对于本文有什么不理解的地方,非常欢迎大家可以在评论中指出讨论。
[1] Quake’s Lighting Model: Surface Caching
[2] A Certain Slant of Light - Past, Present and Future Challenges of Global Illumination in Games
[3] Half-Life 2 / Valve Source Shading
[4] 第十五课:光照贴图(Light Map)
[5] 辐射度算法
[6] Barycentric Coordinate System-Wiki
[7] Radiosity-A Program’s Perspective
[8] Radiosity Overviewer
[9] SwapBuffers的等待,虚伪的FPS
[10] 像素平均值快速求法
[11] Radiosity Algorithm(Computer Graphics)-Wiki