GraphicsLab Project之光照贴图烘焙(一)

作者:i_dovelemon
来源:CSDN
日期:2018-05-19
主题:Radiosity Algorithm, Global Illumination, Barycentric Coordinate


GraphicsLab Project之光照贴图烘焙(一)_第1张图片


引言



早在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)。这样就导致了影子看上去不是全黑的,一个颜色的墙壁会染上其他墙壁的颜色等等效果,见下图。


GraphicsLab Project之光照贴图烘焙(一)_第2张图片

而对于间接光照,现在没有太好的实时渲染方案。最简单的实现间接光照效果的方法,就是对一个静态的场景,预先使用支持GI的渲染器,烘焙出想要的GI效果。比如 这篇文章就讲述了如何使用Blender预先烘焙好光照贴图,然后在程序中使用。如果你的引擎仅仅是希望这样,那么直接使用现成的渲染器来烘焙光照贴图是最省力的做法,效果也不错。

由于我想实现的是Radiosity Normal Map,它的渲染需要对离线渲染器做一些修改,所以需要自己实现一套离线渲染程序,所以就有了这篇文章。当然,本来就是为了学习,做更多的东西才是最好的做法。

光照贴图的形式多种多样。一般来说,我们会给一个合适大小的场景单独烘焙一整张光照贴图,它看起来像这样:

GraphicsLab Project之光照贴图烘焙(一)_第3张图片

好了,在了解了光照贴图的作用之后,我们就需要了解如何烘焙光照贴图了。


辐射度算法(Radiosity Algorithm)



实现烘焙的算法有很多,比如Path Tracer, Photo Mapping还有Radiosity Algorithm等等。本文将要实现的方案是基于Radiosity Algorithm。

这里有一篇文章,很清晰的讲述了Radiosity Algorithm的机理。我不认为我能够讲解的比他的还要清楚明了,所以关于Radiosity Algorithm的解释,我就不在赘述了。当然,如果你和我一样相信他所讲的就是全部了,那么你依然无法根据那篇文章实现烘焙功能,至少效果是不太正确的。所以我在这里讲述一些它所没有涉及的部分。

大体上,基于Radiosity Algorithm的烘焙程序,是由如下的步骤组成的:

  • 预先分割场景,准备LightPatch
  • 循环迭代,计算每一个LightPatch所受到的光照
  • 根据每一个LightPatch最终接受到的光照值,生成LightMap

    下面将依次讲解每一个步骤。


  • 光照烘焙程序



    分割场景



    场景分割方案有多种不同的方式。我这里由于最终需要的结果是一张LightMap,所以就根据LightMap来分割场景。

    正如前面我们讲述了那样,对于一个场景,我们使用一整张LightMap,来表示它的光照烘焙结果,而不是每一个物体一张光照贴图。这就导致了,场景中所有物体的UV,除了本身进行光照渲染时需要使用的访问Albedo,Normal等等的传统UV,还需要另外一套对应于光照贴图的UV坐标,也就是说整个场景的第二套UV坐标是统一在一个UV坐标系里面的。这样才能够统一的访问光照贴图,得到对应的光照结果。

    根据辐射度算法一文的描述,一个LightPatch就对应了光照贴图里面的一个像素。而对于一个LightPatch,我们需要知道它所在场景的点的位置(Position)以及法线(Normal)。那么,对于光照贴图里面的任意一个像素,我们都能够知道它的UV坐标。而模型文件中保存了场景中所有三角形的顶点位置,法线和UV的信息。我们能否根据LightMap中像素的UV坐标,来得到对应的LightPatch所在点的位置及法线信息了。答案当然是可以的了。

    Barycentric coordinate


    在这里我们需要使用三角形的barycentric coordinate system来求出我们需要的信息。关于barycentric coordinate system的详细信息,可以看这里。

    根据wiki的描述,我们能够得到一个三角形的如下关系式:

    r=λ0r0+λ1r1+λ2r2 r = λ 0 ∗ r 0 + λ 1 ∗ r 1 + λ 2 ∗ r 2

    其中:


    (λ0+λ1+λ2=1) ( λ 0 + λ 1 + λ 2 = 1 )
    0λ01 0 ≤ λ 0 ≤ 1
    0λ11 0 ≤ λ 1 ≤ 1
    0λ21 0 ≤ λ 2 ≤ 1
  • r为三角形上一点的信息
  • r0,r1,r2为三角形三个定点的信息


  • 这样,我们就可以把r替换为position和uv,得到如下的对应关系

    p=λ0p0+λ1p1+λ2p2 p = λ 0 ∗ p 0 + λ 1 ∗ p 1 + λ 2 ∗ p 2
    uv=λ0uv0+λ1uv1+λ2uv2 u v = λ 0 ∗ u v 0 + λ 1 ∗ u v 1 + λ 2 ∗ u v 2

    根据上面的两个公式,只要我们知道了三个定点的位置信息和uv信息,在根据三角形中任意一点的位置uv信息,我们就能够得到该uv所对应点的位置信息,而这正是我们想要的。

    分割实现


    根据前面一小节的描述,我们已经能够根据光照贴图中像素的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;
                }
            }
        }
    }

    这里有几个点需要注意。

  • 并不是光照贴图里面所有的像素都是有效的,有些像素本身就没有被使用
  • 根据barycentric coordinate system,在已知三角形三个顶点信息和已知点信息的情况下,求 λ0 λ 0 , λ1 λ 1 , λ2 λ 2 的公式在wiki中已经给出
  • 如果求出的 λ λ 值不满足前面给出的条件,那么就表示该点不在这个三角形中
  • 这里的分割使用的是最粗暴的做法,如果你的lightmap非常大,可能要等一段时间才能够分割完:)


  • 计算LightPatch接受的光照值



    这里的做法和辐射度一文中的做法大体上一样。

  • 在已经知道了LightPatch的位置和法线之后,我们就从LightPatch所在的点向法线方向望去,渲染一个半立方体(hemicube)
  • 然后用这张半立方体贴图乘上一个权重贴图,得到一张新的半立方体贴图
  • 在根据这张新的光照贴图累计所有像素之和,得到LightPatch所接受到的光照。

    这里有两个不同的地方:权重贴图的预计算和最后计算LightPatch所接受的光照。

  • 权重贴图


    实际上这个权重贴图里面存放的是一个名为Form Factor(View Factor)的系数。我们定义两个LightPatch,一个LightPatch为Receiver,即接受其他所有LightPatch所发射过来的光照,另外一个LightPatch为Sender,发射它本身的光照。那么Sender的光照并不是完全等值的被Receiver给接受。根据这两个LightPatch法线,和观察向量以及两者之间的距离不同,会呈现不同的衰减状态,如下图所示:


    GraphicsLab Project之光照贴图烘焙(一)_第4张图片


    所以这个Form Factor的完整定义如下所示:
    Fij=cosθicosθjπr2Aj F i − j = c o s θ i c o s θ j π r 2 A j

    其中:
  • cosθi c o s θ i 为光照方向与Receiver法线方向的余弦值,及Lambert Law
  • cosθj c o s θ j 也是同样的定义,不够在实际使用中我们却不使用它(原因是我们并没有保存Sender的法线),而是近似的做法,使用光照方向与观察向量的余弦值,及辐射度算法一文中透视修正的部分。
  • r r 值的就是Sender的LightPatch投影到半立方体之上后,距离Receiver的距离
  • Aj A j 表示是半立方体上每一个像素的面积

    如果你只考虑了前面两项,你能够得到一个差不多的结果。但是当你渲染球体光源的时候,会发现它的光照半径是一个正方形,不够柔和。如下是更改前和更改后的对比图:

    GraphicsLab Project之光照贴图烘焙(一)_第5张图片


    更多关于FormFactor的知识,看这里。

    如下shader代码是计算实际FormFactor的:
  • #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);
    }


    当然上面的结果也要和辐射度一文一样,需要单位化权重值得到最终的结果。

    LightPatch光照计算


    在辐射度一文中说,计算最后的光照贴图,需要将hemicube中所有像素相加,然后在除以像素总数。我在实际开发的过程中,发现如果这么做了,最后得到的结果就只是黑暗的一片。根据这篇文章的描述,Radiosity Algorithm有很多的变种。比如类似本文的这种,叫gathering-variant,是从Receiver的角度去收集(gathering)其他LightPatch所照射过来的光照。还有如Shooting-variant和Shooting and Sorting-variant,从从Sender的角度,去发射光线,然后更新每一个Receiver的光照。并且,作者对比了几种变种,在迭代100次之后的结果,如下图所示:


    GraphicsLab Project之光照贴图烘焙(一)_第6张图片


    从对比的结果来看,Shooting的变种完爆Gathering的变种。感兴趣的同学可以自行研究如何实现Shooting的变种。

    当然,对于Gathering的变种,我经过尝试之后发现,只要去除掉最后一步除以像素总数的操作,也能够得出比较正确的结果,如题图所示那样。不过也有一点问题,就是多次迭代之后,整张图会变的非常白,暗部缺失。当然这个问题,也可以通过调节光照来缓解。


    一些简单优化



    在不进行任何优化的话,渲染一张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

    你可能感兴趣的:(3D引擎,游戏开发,GPU,OpenGL,算法设计,GraphicsLab,Project,Shader,图形试验室)