
简介光子映射是计算机图形学中实现全局光照、模拟光线间接传播的重要算法在Visual C环境下编写代码可以完整呈现光线从光源发出、经场景多次反弹再到像素着色的过程。提供一个可直接运行学习的光子映射示例工程面向具备一定C基础、希望理解光子投射与光照查询两大环节原理的图形学学习者通过精简代码演示了间接光照计算的完整链路。压缩包内共6个文件由3个C源文件、2个头文件和1个Makefile构建脚本组成整体仅10KB代码量精简适合逐行分析。C源文件承载光子映射的算法流程头文件中定义了光子与向量等核心数据结构Makefile则提供了项目编译入口。目前已有179人学习浏览。实现涵盖光子投射中光子位置、颜色、法线与能量的记录以及光照查询阶段基于光子图邻近信息估算间接光强的方法同时利用OpenGL辅助展示渲染结果代码中还预留了使用kd树等数据结构加速查询的优化接口可作为后续扩展光子聚集、光子烘焙等高阶功能的实验支架。1. photon-map.rar 背后的光子映射管线到底在算什么拿到 photon-map.rar第一次跑通的人通常会看到两类结果一类是焦散区域极锐利的玻璃球渲染另一类是漫反射墙角干净柔和的光晕过渡。这两类效果在纯路径追踪里都需要极高的每像素采样才能压住噪点而 Photon Mapping 的思路是预先从光源发射几百万个光子让它们在场景里弹射、折射、吸收把存活下来的光子写进一张离散的“光子图”。渲染时反查这张图用邻域光子密度还原出辐照度。这条管线把计算拆成预处理和渲染两个阶段在焦散、体积光、半透明物体这类高频光照场景里能用远低于路径追踪的采样数获得平滑结果。下面从光子发射、kd-tree 存储到密度估计与最终聚集把整条链路完整拆开。2. 光子发射与追踪Photon Mapping 的能量如何从光源抵达表面2.1 光源采样决定每个光子携带多少能量光子映射的第一步是确定光子从哪里出发、携带多少能量。原则上发射光子的分布要与光源的实际辐射分布一致否则最后还原的绝对亮度是错的。不同光源类型要区分采样光源类型发射点采样方向采样典型场景点光源固定点球面均匀分布灯泡、白炽灯矩形面光源面积均匀采样余弦加权半球窗口、显示器平行光源场景包围球表面固定方向太阳光IES 光度学光源面片均匀IES 分布函数射灯、路灯每次采样都要记录概率密度值 pdf因为后续做辐照度重建时需要用它补偿非均匀采样的偏差。每个光子的能量等于光源总功率除以本次发射的光子总数这样所有光子的能量之和与光源真实功率严格相等渲染结果才能对应到辐射度单位。void sampleLight(const Light light, int photonCount, Photon photon) { if (light.type POINT) { photon.origin light.position; photon.direction uniformSphere(); photon.energy light.power / (4.0f * M_PI * photonCount); } else if (light.type AREA) { glm::vec2 uv random2D(); photon.origin light.corner light.u * uv.x light.v * uv.y; photon.direction cosineHemisphere(); photon.energy light.power / photonCount; } }点光源的能量要额外除以 4π因为点光源向整个球面发射面光源只向半边空间发射能量平均分到每个光子即可。代码里的photon.energy是 RGB 向量而不是标量因为光源功率和表面反射率都带颜色分量光子能量在多次弹射中会逐步被染上场景色这是全局光照表现颜色渗透效果的基础。2.2 光子与表面交互反射、折射与存储时机光子与场景求交后要做三件事记录交点位置、决定是否把当前光子写入光子图、决定光线下一步去向。这个循环一直持续到光子被吸收或超出最大弹射次数。def trace_photon(photon, scene, max_bounces100): for bounce in range(max_bounces): hit scene.intersect(photon.origin, photon.direction) if not hit: break photon.origin hit.position store_photon(hit, photon) # 每个交点都记录一次 if hit.is_specular: photon.direction reflect(photon.direction, hit.normal) elif hit.is_diffuse: photon.direction cosine_hemisphere(hit.normal) elif hit.is_refractive: photon.direction refract(photon.direction, hit.normal, hit.eta) # 俄罗斯轮盘按反射率决定是否终止 prob max(hit.albedo.r, hit.albedo.g, hit.albedo.b) if random.random() prob: break photon.energy * hit.albedo / prob每个交点都存储光子这点和直觉可能相反不是只有打到漫反射面才存。存储时记录位置、入射方向和能量后续渲染阶段对光子按表面类型分类——漫反射面用密度估计镜面反射面只传递方向这样能同时处理焦散和镜面反射路径。俄罗斯轮盘的作用是提前终止能量已经衰减到几乎不影响画面的路径避免把计算浪费在无效弹射上。反射率为 0.9 时90% 的概率继续能量除以 0.9 保持期望不变反射率为 0.1 时90% 的概率停止剩余 10% 的路径继续追踪。2.3 光子数量、内存占用与轮盘阈值的联动关系光子数量并非越大越好。单个光子记录包含位置 3 个 float、能量 3 个 float、入射方向 3 个 float加上分裂标志约 40 字节。100 万光子就是 40 MB构建 kd-tree 后节点结构和索引再翻一倍总内存约 80 MB。200 万光子在普通场景里就逼近 160 MB并且发射阶段的时间也线性上涨。提示光子发射数量和像素采样数量是两个独立参数。前者在预处理阶段一次付清后者在渲染阶段每像素重复计算。调试时先固定光子数调采样再反过来不要同时动两个。俄罗斯轮盘的终止阈值建议与能量值挂钩而不是只看概率。常见做法是设置一个最小能量阈值比如 RGB 三个分量都低于 0.01 就直接截断因为继续弹射对图像贡献低于人眼可感知的亮度层级。光子发射阶段还要注意焦散路径的比例折射类表面的光子应占比更高因为焦散能量高度集中需要更密集的光子才能表达出锐利的亮斑。3. 光子图存储kd-tree 是光子映射的查询骨架3.1 为什么选 kd-tree 而不是均匀网格光子图的核心操作是渲染时对任意着色点做最近邻查询。场景中的光子分布极不均匀光源正对区域每平方单位可能有上万个光子阴影处可能一个光子都没有。均匀网格要么在稀疏区域浪费大量空桶要么在密集区域桶太大导致查询退化。kd-tree 按维度递归二分节点数量与光子数量成线性关系查询复杂度平均 O(N^(2/3))且能自适应跟随光子密度变化。这里要区分两棵不同的 kd-tree一棵用于场景三角形求交节点存的是三角形包围盒另一棵用于光子邻域查询节点存的是光子位置。两者用途完全不同工程上不要把光子插进场景树里。3.2 平衡构建nth_element 与叶子阈值构建平衡 kd-tree 的标准做法是在每个节点按当前光子集合最长的包围盒维度排序取中位数作为分裂点左子树放小于中位数的光子右子树放大于的。struct PhotonNode { glm::vec3 pos; glm::vec3 power; glm::vec3 dir; int splitAxis; PhotonNode* left; PhotonNode* right; }; PhotonNode* buildKDTree(std::vectorPhoton photons, int depth) { if (photons.empty()) return nullptr; int axis depth % 3; size_t mid photons.size() / 2; std::nth_element(photons.begin(), photons.begin() mid, photons.end(), [axis](const Photon a, const Photon b) { return a.pos[axis] b.pos[axis]; }); if (photons.size() 8) { // 叶子节点直接存储全部光子 return new LeafNode(photons); } PhotonNode* node new PhotonNode; node-pos photons[mid].pos; node-splitAxis axis; std::vectorPhoton left(photons.begin(), photons.begin() mid); std::vectorPhoton right(photons.begin() mid 1, photons.end()); node-left buildKDTree(left, depth 1); node-right buildKDTree(right, depth 1); return node; }构建时最重要的优化是用nth_element而非sort。nth_element平均线性时间找到中位数复杂度远低于整体排序。叶子节点阈值设为 8 左右的目的是控制递归深度和查找开销太大会让最近邻搜索线性扫描过多光子太小会让树深度增加导致栈溢出。这段实现为了可读性用了向量拷贝工程上可以先按维度排序再做原地重排把构建 100 万光子的时间从秒级压到几百毫秒。3.3 最近邻搜索的剪枝条件渲染阶段对每个着色点查询最近邻时搜索从根节点出发维护一个容量为 k 的最大堆堆顶始终是当前第 k 近的距离。访问一颗子树前先判它是否有机会包含更近的光子。void findKNearest(PhotonNode* node, const glm::vec3 target, int k, std::priority_queuestd::pairfloat, Photon* heap) { if (node nullptr) return; float dist glm::distance(target, node-pos); if (heap.size() k) { heap.push({dist, node}); } else if (dist heap.top().first) { heap.pop(); heap.push({dist, node}); } float planeDist target[node-splitAxis] - node-pos[node-splitAxis]; PhotonNode* nearSide planeDist 0 ? node-left : node-right; PhotonNode* farSide planeDist 0 ? node-right : node-left; findKNearest(nearSide, target, k, heap); if (std::abs(planeDist) heap.top().first) { findKNearest(farSide, target, k, heap); } }剪枝条件是两个堆未满时必须查另一侧堆已满时分割平面距离小于当前最远邻居距离才进另一侧。这个判断在大面积空区域场景里作用非常明显能跳过整棵子树查询耗时是朴素遍历的几十分之一。这里的heap.top().first是当前第 k 近的距离会随着搜索推进不断变小所以剪枝会越来越严格。4. 渲染阶段从光子图到像素的辐射度恢复4.1 密度估计的两种带宽方式KNN 与固定半径光子图建立后渲染时对每个着色点做邻域查询把邻域内光子的能量求和除以采样区域面积得到该点的辐射度估计这个过程叫密度估计。带宽决定邻域取多大两种方式各有取舍带宽方式实现优势劣势常用参数固定半径半径 r 球内所有光子能量和除以 πr²无偏、平滑低密度区易空洞r 0.5 2.0 场景单位KNN固定 k 个最近光子半径自适应密度不均时稳定有偏、边界过平滑k 50 300KNN 的“有偏”体现在它会自适应地扩大或缩小搜索半径导致低密度区域的辐射度被系统性低估高密度区域被高估。固定半径在光子数趋于无穷时收敛到正确值是无偏的但在有限光子数下固定半径很容易落空。工程里更常用 KNN 基础加半径上限的组合如果 k 个邻居的半径超过上限比如 5 个场景单位就放弃该点的间接光直接返回环境光或零值。4.2 焦散区域的带宽选择与误差来源焦散是光子映射最擅长的场景也是最容易出错的场景。光束通过玻璃杯或水面聚焦后形成的亮斑其能量密度比周围漫反射区域高出几个数量级。如果用较大的固定半径亮斑会被糊成一团大范围的亮色用较小的半径则要求极高的光子密度否则亮斑内部全是颗粒噪点。焦散区域建议 k 取 50 左右漫反射区域 k 取 300 左右。如果你手里的渲染器支持按材质区分光子图把焦散光子单独存一张图、单独调带宽是控制误差最直接的手段。判断当前带宽是否合适的方法是渲染一张只有焦散光子的中间图如果亮斑边缘超过 2 个像素的模糊过渡说明半径偏大需要减半再试。4.3 最终聚集去除光子图固有的低频模糊单纯用密度估计渲染出来的图像会有一层“磨砂感”特别是在漫反射面之间的颜色渗透区域。原因是光子图密度估计本质上是一个低通滤波把高频能量抹平了。为了保留漫反射壁之间的平滑过渡同时又让细节清晰常见做法是在渲染阶段加一次最终聚集final gather。def shade_with_final_gather(hit, scene, photon_map): direct sample_direct_light(hit) # 直接光照单独采样 indirect 0.0 for _ in range(gather_samples): ray cosine_sample_hemisphere(hit.normal) ghit scene.intersect(hit.pos, ray) if ghit and ghit.is_diffuse: indirect photon_map.estimate_knn(ghit.pos, k64) * (1.0 / gather_samples) return direct indirect * hit.albedo最终聚集的采样数不需要高4 到 16 条足够。它的作用是补偿密度估计的低频误差而不是取代光子图。如果像素全部用最终聚集计算那退化成普通分布式光线追踪如果全部依赖光子图画面就发糊。两者配合时光子图负责大方向的光照分布最终聚集负责把像素级别的细节补齐。5. 调试 Photon Mapping 时的 3 个参数调优技巧5.1 光子数不足的快速诊断光子数不足的表现是低频块状噪点而不是路径追踪那种高频闪烁。诊断方法把渲染分辨率降到 32×32观察大色块之间的明暗是否仍有显著波动。如果有基本可以断定是光子密度不够而不是采样不足。此时优先增加光子发射数量而不是像素采样数因为光子图是预处理阶段一次性的成本增加它不影响每像素渲染时间。反之如果降分辨率后明暗稳定说明问题出在像素采样的收敛上应调最终聚集样本数。5.2 光源附近亮斑与双重计数密度估计会把光源直接照射区域的光子也算进邻域和导致光源附近的表面出现一圈不自然的亮斑。这本质是双重计数直接光照已经通过直接采样算过一遍光子图又把它估了一遍。修复方式是在存储光子时给直接光照光子打标记渲染时只统计非直接光子参与密度估计。提示不要试图在光子数值上做减法来补偿双重计数那会把间接光照也推偏。正确做法是让光子图只负责多弹射的间接光。5.3 调参顺序的一个可变套路画面现象先调再调整体模糊减小固定半径 r 或降低 KNN 的 k增加光子总数焦散边缘糊k 降到 50 以下单独提高焦散光子占比暗部快速闪烁增加最终聚集样本数增大带宽半径内存吃紧减少光子总数把光子图改为稀疏存储调试顺序的核心思路是先消除最影响观感的因素再考虑收敛质量。光子映射与路径追踪最大的区别在于它允许按视觉区域分配计算资源焦散区域加光子密度漫反射区域加最终聚集样本彼此独立调整。验证一个改动是否有效的办法是固定摄像机视角渲染两张不同参数的图像对同一像素输出差值热力图。用 8 个光子和 80 万个光子各渲染一次观察差异集中在哪些区域很快就能理解 bandwidth 参数在整个管线里扮演的角色。本文还有配套的精品资源点击获取