文章

Lightmap 总结

​ ​ 在翻译完《Ray Tracing: The Rest of Your Life》之后,我一直有一种研究 Real-Time GI 的冲动。这本书把蒙特卡洛路径追踪的"内功"讲得非常透——估计器、重要性采样、PDF、分层采样——但它的世界是离线的:一帧可以渲几个小时,样本数随便堆。而 Real-Time GI 的命题恰好相反:在毫秒级的预算内逼近路径追踪的结果。这两者之间隔着巨大的工程鸿沟,与其直接扎进论文堆,不如从工程上最经典、也最"朴实"的方案入手——索性就从 Lightmap 开始

​ ​ 选它作为起点有三个理由:

  1. 管线完整:一套 Lightmap 烘焙器会把参数化、装箱、光线求交、蒙特卡洛积分、降噪、边界处理、压缩存储全部走一遍,麻雀虽小五脏俱全;
  2. 与已学知识无缝衔接:烘焙器的核心就是"带空间缓存的路径追踪",书里的每一个概念几乎都能在管线里找到落点;
  3. 它是后续方案的地基:Light Probe、Irradiance Volume、PRT 乃至 DDGI,本质上都是"换个粒度存路径追踪结果",Lightmap 是其中最直观的一种。

​ ​ 本文记录这套烘焙管线的完整设计思路

​ ​ 主要流程就是把所有Renderer的UV2映射区域,整合到多个Lightmap里,每个Renderer占一个区域,根据LightmapIndex和LightmapScaleOffset进行采样,在Unity中,只需要把贴图设置好,Unity引擎在渲染每个Renderer时,会自动把LightmapIndex和LightmapScaleOffset设置好,通过调用内置函数采样即可。

流程图.png


一、Lightmap核心思想

​ ​ 一句话:把路径追踪提前算到贴图中,运行时直接采样

  • 离线阶段(烘焙):对静态场景的每个表面点,用路径追踪算出它"收到的一切光"(直接光 + 任意次反弹的间接光),结果写入一张纹理;
  • 运行时:静态几何用第二套 UV(Lightmap UV)采样这张纹理,乘以材质反射率,以几乎为零的成本获得全局光照。

二、LightmapAltas

​ ​ LightmapAtlas 是整条烘焙管线的基础数据结构:它以 UV2 为参数空间,把场景中所有 Renderer 的表面"摊平"进来,每个 Renderer 占据其中一块区域。这样一来,每个 texel 就不再只是一个颜色值,而是对应世界空间中的一小片表面。为了让后续烘焙知道"每个像素该为哪片表面算光",Atlas 需要为每个像素记录三类信息:

  • RendererIndex:该像素对应哪个 Renderer。记录索引而非引用,既能判断像素是否有效(未被任何 Renderer 覆盖的像素不参与烘焙,留给 Dilate 填充),又能随时反查该 Renderer 的网格与材质(albedo、emissive 等);
  • 世界空间坐标:该像素所对应表面点的位置,烘焙时作为半球采样的光线发射起点;
  • 世界空间法线:该表面点的朝向,半球采样需要以它为轴构建。

​ ​ 生成方式:把每个 Renderer 的三角形按 UV2 映射光栅化到 Atlas 中各自的区域,某个 texel 落在哪个三角形内,就用重心坐标插值出该点的世界坐标和法线,同时记下所属的 RendererIndex:

internal struct LightmapBakeSurface
{
    public bool valid;
    public Vector3 position;
    public Vector3 normal;
    public Vector3 geometricNormal;
    public int rendererIndex;
}

​ ​ 有了这份映射,烘焙逻辑就非常直接了:遍历所有有效 texel,从其世界坐标出发、以法线为轴在半球面上采样多根光线,每根光线与整个场景求交,交点表面反射出来的 radiance,就是沿该方向传到本表面点的入射 radiance(记作 L_i,它内部又包含交点的自发光、直接光照与更深的反弹,这里先当成黑盒)。把多根光线得到的 radiance 做 Monte Carlo 加权平均,就积分出了该表面点的间接光受光情况(irradiance):

E(p) = ∫_Ω Li(p, ω) · (n·ω) dω ≈ \frac{1}{N} Σ \frac{Li(p, ω_s) · (n·ω_s)} { pdf(ω_s)}

​ ​ 其中 pdf 取余弦加权分布(​pdf = \frac{cosθ}{π},正是《Ray Tracing: The Rest of Your Life》里 importance sampling 的标准操作),此时估计器中的 ​cosθ​π 会直接约掉——多根光线结果的均值乘上该 texel 的 albedo,就是它的间接光。

三、Trace Texels

​ ​ Trace Texels 是烘焙的核心:对 Atlas 中每个有效 texel,以其世界坐标为起点、以其法线为半球轴,采样多根光线(SPP)做路径追踪,把"这个表面点一共收到多少间接光"算清楚。整个过程就是一个标准的递归式路径追踪器:每根光线先与场景求交拿到交点表面数据,交点贡献自发光 + 直接光,再随机弹一个方向递归追踪更深层的弹射,最后把每一跳的贡献按采样权值累乘混合回来。

3.1 每个texel采样多根光线(SPP)

​ ​ 单根光线的蒙特卡洛估计噪声很大(方差按 ​1/N 收敛),所以每个 texel 不是弹一根,而是弹 SPP(Samples Per Pixel) 根方向互不相同的光线,各自完整走完一条路径后取平均。texel 级的伪代码:

foreach (texel in atlas)                     // 只处理 valid 像素
{
    sum = 0;
    for (int s = 0; s < SPP; s++)            // 每个texel弹SPP根光线
    {
        DirectionSample ds = SampleDirection(texel.position, texel.normal, ref random);
        Vector3 Li = TraceRadiance(
            new Ray(texel.position + texel.normal * RayBias, ds.direction), 0, ref random);
        sum += EvaluateLambertianSampleWeight(texel.albedo, cosine, ds.pdf) * Li;
    }
    texel.indirect = sum / SPP;              // 蒙特卡洛平均
}

​ ​ 两点说明:texel 自身的直接光是另行计算后直接相加的,不参与这个平均;RandomState 由 texel/sample/bounce 的哈希初始化,保证多线程可并行、结果可复现、可断点续烘。SPP 与降噪是同一件事的两面——SPP 越大噪声越小、烘焙越慢,所以工业烘焙器(如 Unity Progressive Lightmapper)普遍做成 progressive:每轮每 texel 只追加少量样本、增量更新均值,随时可预览、随时可停。

​ ​ 下面是路径追踪核心 TraceRadiance 的实现(连同权值计算 EvaluateLambertianSampleWeight),后续小节按代码的执行顺序逐段拆解:

private Vector3 TraceRadiance(Ray ray, int depth, ref RandomState random)
{
            if (!FindOpaqueSurface(ray, m_Settings.MaxRayDistance, out LightmapRaycastHit hit, out PreparedMaterial material))
            {
                Color environment = m_Settings.EnvironmentColor.linear * m_Settings.EnvironmentIntensity;
                return new Vector3(environment.r, environment.g, environment.b);
            }

            Vector4 sampledBase = material.SampleBaseColor(hit.uv);
            Vector3 vertexColor = new Vector3(hit.vertexColor.r, hit.vertexColor.g, hit.vertexColor.b);
            Vector3 diffuseReflectance = Vector3.Scale(
                new Vector3(sampledBase.x, sampledBase.y, sampledBase.z),
                vertexColor) * (1.0f - material.metallic);
            Vector3 emittedRadiance = material.doubleSided || hit.frontFace
                ? material.emission
                : Vector3.zero;
            Vector3 radiance = emittedRadiance + EvaluateDirectLighting(hit, diffuseReflectance);

            if (depth + 1 >= m_Settings.MaxBounces || diffuseReflectance.sqrMagnitude <= 1e-10f)
            {
                return radiance;
            }

            Vector3 geometricNormal = OrientGeometricNormal(hit.geometricNormal, hit.shadingNormal);
            Vector3 rayOrigin = hit.position + geometricNormal * m_Settings.RayBias;
            DirectionSample directionSample = SampleDirection(rayOrigin, hit.shadingNormal, ref random);
            float cosine = Mathf.Max(0.0f, Vector3.Dot(hit.shadingNormal, directionSample.direction));
            if (cosine <= 0.0f ||
                Vector3.Dot(geometricNormal, directionSample.direction) <= 0.0f ||
                directionSample.pdf <= MinimumPdf)
            {
                return radiance;
            }

            Ray bounceRay = new Ray(rayOrigin, directionSample.direction);
            Vector3 incoming = TraceRadiance(bounceRay, depth + 1, ref random);
            Vector3 sampleWeight = EvaluateLambertianSampleWeight(
                diffuseReflectance,
                cosine,
                directionSample.pdf);
            radiance += Vector3.Scale(sampleWeight, incoming);
            return radiance;
}

internal static Vector3 EvaluateLambertianSampleWeight(
            Vector3 diffuseReflectance,
            float cosine,
            float pdf)
{
            if (cosine <= 0.0f || pdf <= MinimumPdf)
            {
                return Vector3.zero;
            }

            return diffuseReflectance * (cosine / (Pi * pdf));
}

3.2 求交与交点表面数据

​ ​ 每根光线(含每一次弹射)的第一步都是与全场景求交:FindOpaqueSurface 沿射线找最近的 opaque 表面,返回命中点数据(position、shadingNormal、geometricNormal、uv、vertexColor、frontFace)及其材质。若直到 MaxRayDistance 都没有命中,就把环境光(EnvironmentColor.linear * EnvironmentIntensity,注意先转 linear 空间)作为该方向的 radiance 返回——这就是环境/天光对 Lightmap 的贡献来源。

​ ​ 拿到交点后,先算出该表面的漫反射反射率 diffuseReflectance,也就是 Lambert BRDF ​f_r = \rho/\pi 中的 ​\rho

\rho = baseColor(uv) \times vertexColor \times (1 - metallic)
  • baseColor 在交点 uv 处采样贴图——烘焙用的是和运行时同一张贴图,材质才对得上;
  • vertexColor(顶点色染色);
  • ​(1 - metallic):金属度越高,入射能量越集中在镜面反射叶,漫反射分量越小;纯金属(metallic = 1)没有漫反射,既不向 Lightmap 贡献间接光,也没有间接光可收。

3.3 交点的自发光 + 直接受光

​ ​ 交点处本次命中带回来的光由两项组成:

L = L_e + L_{direct}
  • 自发光 ​L_e:面光源不需要任何特殊采样逻辑——路径随机撞上它,它的 emission 就是这次命中带回的光。唯一的细节是朝向判断:单面材质只在正面发光(doubleSided || hit.frontFace),背面不算,防止 emission 从背面"透"出来;
  • 直接光 ​L_{direct}EvaluateDirectLighting 在交点处对光源显式采样(含 BRDF 求值),而不是等光线随机撞上光源——小面积光源在被积函数里是尖峰,纯靠随机命中噪声会爆炸,这正是《Ray Tracing: The Rest of Your Life》里"Sampling Lights Directly"一章的动机。

3.4 递归:多次弹射的radiance

​ ​ 光在场景里不止弹一次。交点再沿半球随机采一个方向(SampleDirection,以 shadingNormal 为轴),递归调用 TraceRadiance 得到该方向的入射 radiance,加权累加到本跳结果上,就得到了任意多次弹射的间接光。递归的终止条件有两个,都是确定性的:

  • depth + 1 >= MaxBounces:弹射预算用尽,返回当前已累计的 radiance(截断会让结果略偏暗,漫反射场景 MaxBounces 给 3~5 通常够用;另一种无偏做法是 Russian Roulette 按存活概率终止);
  • diffuseReflectance.sqrMagnitude <= 1e-10:反射率接近纯黑的表面把光全部吸收,后续弹射贡献必然为零,提前返回省一次全场景求交。

​ ​ 弹射前还有两个必处理的工程细节:

  • 起点偏移rayOrigin = hit.position + geometricNormal * RayBias。新光线若从交点原样发出,浮点误差会让它打到自己所在的三角形(自相交 acne),Lightmap 上表现为黑斑噪点;
  • 双法线校验:除了 cosine > 0,还要求 geometricNormal·direction > 0。当 shadingNormal(可能来自法线贴图)与 geometricNormal 偏差过大时,采样方向可能落到几何表面"以下"——放行就会漏光,这里直接按零贡献丢弃(同时丢弃 pdf 过小的退化样本)。

3.5 按采样权值混合

​ ​ 递归返回的 incoming 是入射 radiance ​L_i,要换算成本表面的出射贡献,必须乘上这次采样在蒙特卡洛估计器里的采样权值——BRDF × cosine / pdf:

w = f_r \cdot \frac{\cos\theta}{pdf} = \frac{\rho}{\pi} \cdot \frac{\cos\theta}{pdf}

​ ​ 这正是 EvaluateLambertianSampleWeight 里那行 diffuseReflectance * (cosine / (Pi * pdf))。注意它是一个 RGB 向量而非标量——​\rho 是带颜色的,红墙反弹出来的光就偏红,三个通道各自吞吐各自的能量。特别地,若 SampleDirection 采用余弦加权采样(​pdf = \cos\theta / \pi),权值中的 ​\cos\theta​\pi 恰好全部约掉、退化为 ​\rho,与 1.2 节的推导闭环;代码不写死这一假设,而是保留通用的 ​pdf 参数,采样策略换成别的,权值依然正确。

​ ​ 把整条路径展开,就能看到"按采样权值混合"的全貌:一条路径上第 ​k 跳的贡献,等于沿途每一跳权值的连乘(throughput)乘以该点的 ​(L_e + L_{direct})

L_{path} = \sum_k \left( \prod_{j<k} w_j \right) \cdot (L_e + L_{direct})_k

​ ​ 所以 TraceRadiance 的每一跳都在做同一件事:本跳的自发光+直接光直接计入返回值,下一跳递归的结果乘上本跳权值再计入——radiance 沿路径逐跳回流,权值沿路径逐跳衰减,一个 texel 的 SPP 条路径各自混合完毕后取平均,就是它最终的间接光结果。

四、加速结构

​ ​ 第三章的 TraceRadiance 里,每一根光线、每一次弹射的第一步都是 FindOpaqueSurface 与全场景求交。如果求交是暴力遍历——对场景所有三角形逐个测试——单次求交成本是 O(三角形数),而总计算量还要再乘上"有效 texel × SPP × 弹射次数",求交会占掉烘焙时间的绝对大头(路径追踪里七到九成时间花在求交是常态)。所以必须用空间加速结构,把"这根光线可能撞到什么"快速缩减到它路过的那一小片空间。本烘焙器用的是基于 VTCode 编码的稀疏八叉树

4.1 稀疏八叉树与 VTCode 编码

​ ​ 普通八叉树是满分裂的:节点一分就是 8 个子节点,哪怕里面什么都没有——大场景下空节点把内存直接吃爆(实践里 4000m×4000m×4000m 的满八叉树在 Unity 里构建即崩溃)。稀疏八叉树只在子节点与几何体相交时才生成子节点,树上的节点都有内容,不在树上的就是空。节点分三类:

  • 稀疏节点:有子节点的中间节点,只有这类节点会存进字典;
  • 阻塞节点:被"填满"的最小单元(对烘焙而言即含几何的最小体素),不占字典条目——由父稀疏节点的占用掩码标记;
  • 空节点:不在树上的节点。

​ ​ 每个节点由 ​(X, Y, Z, Layer) 标识,编码成一个 UInt32,这就是 VTCode——我在《稀疏八叉树的编码总结》里提出的 Morton 码改进:三个 10bit 坐标直接拼接,不做按位交错、不插 0、不需要魔数,再拼一个层标记位——编码中最右侧的 1 所在的位就是层号:

// 编码:三坐标直接拼接 + 层标记位(最右侧的1)
uint Encode(int x, int y, int z, int layer) =>
    ((uint)((x << 20) | (y << 10) | z) << (layer + 1)) | (1u << layer);

// 解码层号:取最右侧的1,再取对数
uint RightMostBit(uint v) => (v & (v - 1)) ^ v;
int  DecodeLayer(uint code) => (int)Math.Log2(RightMostBit(code));

// 父节点:每维坐标右移截断最低位,层 - 1
// 子节点 i:每维坐标左移一位,把 i 的各 bit 写入各维最低位(Z-Order),层 + 1

​ ​ 几个关键性质:

  • 节点尺寸 ​size = 2^{Layer}:第 0 层是最小体素(1 个单位),第 10 层是根(1024 单位);坐标编码的就是体素 AABB 的 Min 角,配合 size 即可还原包围盒(Min-Size 表示);
  • 层越高、坐标有效位越少(Layer L 的坐标必然是 ​2^L 的倍数),层标记位"吃掉"的恰好是坐标腾出的高位,所以 32 bit 永远够用;
  • 存储:字典 Key = 稀疏节点的 VTCode,Value = 一个 byte 的占用掩码(第 i 位为 1 表示第 i 个子节点有内容)。查询某编码时若不在字典中,向上回溯找到所属稀疏节点、查掩码位,即可区分阻塞节点与空节点;
  • 编码定义在树空间(原点 + 缩放比例映射到世界空间),叶子单位距离不必是 1 米,由比例灵活控制;超过 1024³ 的大场景按 1024³ 均匀分块,每块一棵树。

4.2 构建:最小体素下的 Renderer 与表面数据

​ ​ 烘焙开始前,把所有参与烘焙的 Renderer 的三角形插入八叉树:从根递归下钻,三角形与哪些子体素相交就往哪钻,钻到最大深度为止。在最小体素这一层,记录该体素所包含的 Renderer 及表面数据——即"体素 →(RendererIndex, 三角形列表)"的映射。这一步做完,求交的候选集就从"全场景三角形"缩到了"光线穿过的少数最小体素里的三角形":

void Insert(VoxelNode node, Triangle tri, int rendererIndex)
{
    if (node.layer == 0)                            // 最小体素
    {
        node.triangles.Add((tri, rendererIndex));   // 记录表面数据 + 所属Renderer
        MarkBlockedUpwards(node);                   // 逐层向上置占用掩码
        return;
    }
    foreach (var child in node.ChildrenIntersecting(tri))
        Insert(child, tri, rendererIndex);          // 只往相交子节点下钻(稀疏性所在)
}

4.3 查询:光线穿过八叉树

​ ​ FindOpaqueSurface 的求交就是沿光线遍历八叉树:按光线经过的顺序依次访问体素(递归下降或 DDA 均可)——空子树(掩码位为 0)整枝剪掉;碰到含几何的最小体素,只对其中记录的三角形做精确相交测试。由于体素是由近及远访问的,一旦在当前体素内命中且命中点不超过该体素的出口 t,即可立即返回——后面的体素不可能更近,全部不用再看。单次求交成本从 O(全部三角形) 降到 O(穿过的体素数 + 这些体素内的三角形数):

foreach (voxel in RayMarch(ray))                    // 光线由近及远经过的最小体素
{
    foreach ((tri, idx) in voxel.triangles)
        if (Intersect(ray, tri, out float t) && t <= voxel.exitT)
            return 最近命中;                        // 提前终止
}
return false;                                       // 未命中 → 返回环境光

​ ​ 一个正确性细节:三角形会跨越多个体素(插入时与所有相交体素都记录了一份),所以提前终止必须校验 ​t \le voxel.exitT——否则可能把"本属于下一个体素的更近命中"错过后漏判。

4.4 为什么不是 BVH

​ ​ 诚实地说,其他加速结构如 BVH 可能会更快,但我没有尝试过。BVH 是光线求交的业界事实标准(Embree、RTX 硬件光追都是 BVH):SAH 构建出的树对射线分布更优,叶节点内三角形连续存储对缓存更友好,遍历分支也更少。选八叉树的理由是工程性的:VTCode 稀疏八叉树是我已经在用的空间划分基建,有现成的成熟实现可以复用;字典式稀疏存储实现简单、内存友好;对离线烘焙来说求交速度够用,整体瓶颈还可以靠 SPP 和并行去调。把它换成 BVH 对比,是这个烘焙器后续一个明确的优化点。

五、不足之处

4.1 分辨率靠手工调节 LightmapScale,采样率不足时结果发糊

​ ​ 每个 Renderer 在 Atlas 里占多大区域,由它的 LightmapScale(叠加全局 texel density)决定,而这个参数没有自动的正确值:一块大面积的墙和一个小物件若分到差不多的 texel 数,墙的每个 texel 就要覆盖很大的世界空间面积(footprint 过大),其上的光照细节——拐角的 AO、细缝里的间接光——全被平均到一个 texel 里,烘焙结果发糊发平,运行时采样时体现为采样率不足;反过来盲目调大 scale,又会白白挤占 Atlas 空间。实践中只能靠美术/TA 按物件的重要性逐个调 LightmapScale,在"分辨率够用"和"Atlas 不爆"之间人肉折中——这是一项依赖经验的体力活,调少了糊、调多了费,且问题往往要到烘焙出图后才能被发现,返工成本高。

4.2 CPU 端计算太久

​ ​ 这套烘焙跑在 CPU 端:求交(BVH 遍历)、材质求值、递归追踪全都是 CPU 代码。路径追踪的计算量 = 有效 texel 数 × SPP × 平均弹射次数 × 每次求交成本,全都是乘上去的——场景一大,这个乘积轻易就能把烘焙时间拖到几十分钟甚至数小时,烘焙期间 CPU 满载,工作流被卡住:改一盏灯、挪一个物件,都要付出一次完整重烘的代价(Lightmap 不支持局部更新,任何改动都是全量重算)。要缓解只有几条路:多线程/多机并行、提高 SPP 换 AI 降噪降 SPP、或者干脆搬到 GPU 上烘——但这些都是工程优化,改变不了"离线、全量、慢"的本质。

4.3 Lightmap 很大

​ ​ 存储是第三重代价。Lightmap 必须存 HDR 结果(间接光叠加可超 1.0,存 LDR 会截断高光、画面发灰),一张 2048×2048 的 RGB 半精度浮点图就是 16MB,场景拆成多张 Atlas、再加上 Direcitonal Lightmap 的额外方向图,总量轻松上百 MB;还要再叠加运行时的 mip 链和 BC6H 压缩块。这些纹理要常驻显存、随场景流式加载,内存与加载带宽都被吃掉一块。更尴尬的是,其中大量 texel 的内容是低频的——一面均匀受光的墙,几百个 texel 存的几乎是同一个值——却仍按完整分辨率占着内存。分辨率调低能省空间,但又会撞回 4.1 的采样率不足问题,三个不足彼此纠缠,构成 Lightmap "静态方案"的根本约束。

​ ​ 这些不足的根源是同一个:光照被烘死在了纹理空间里。分辨率、耗时、存储全部在离线阶段一次定型,运行时一个字节都改不了。后续的 Real-Time GI 方案正是沿着这三条不足演进的:Light Probe / SH 把"存什么"从纹理降到稀疏点,Voxel Cone Tracing、DDGI 用运行时更新的结构换掉"离线定型",ReSTIR 则把样本本身做成时空缓存——这条演进线,也是接下来研究 Real-Time GI 的主线索。

六、Lightmap + SSGI

image-20260817033008056.png

许可协议:  CC BY 4.0