《游戏引擎实现原理》13.2 级联阴影与投影矩阵拟合
一张固定分辨率的阴影贴图罩住整个视锥,近处必然糊、远处必然浪费。级联阴影把视锥沿深度切成几级,每级单独拟合一个光源空间正交盒,并在相机移动时把正交盒对齐到 texel 网格。本节在 helio 里实现切分、角点提取与拟合,再在 GPU 上复算逐级联的包围盒。
category
一张固定分辨率的阴影贴图罩住整个视锥,近处必然糊、远处必然浪费。级联阴影把视锥沿深度切成几级,每级单独拟合一个光源空间正交盒,并在相机移动时把正交盒对齐到 texel 网格。本节在 helio 里实现切分、角点提取与拟合,再在 GPU 上复算逐级联的包围盒。
光照决定物体长什么样,阴影决定它站在哪。本节在 helio 上从零实现阴影贴图:先从光源视角把遮挡物渲染成一张光空间深度图,再把相机视角的地面点投影回光源空间做深度比较,最后在真实 WebGPU 设备上回读像素,与 CPU 解析参考逐点比对。
上一节把单个光源的衰减算清楚,但场景里往往有上百个光源,逐物体遍历全部光源太贵。集群光照把视图空间切成三维网格,每个集群预存一份命中的光源列表。本节在 helio 里实现集群网格与光源装配的 CPU 参考,再用 compute 着色器在真实 WebGPU 设备上复算并逐集群比对。
上一节把着色路径打通,这一节解决「光从哪来、怎么变弱」。方向光没有距离衰减,点光用 UE4 的窗口化反平方,聚光再叠一个锥形衰减。本节在 helio 里实现三类光源的衰减公式,并用一个 compute 着色器在真实 WebGPU 设备上回读每个距离的衰减值,与 CPU 参考逐样本比对。
光照不是一句「乘以点积」,而是「法线 + 光方向 + 视线 + 材质」到一根颜色的映射。本节在 helio 里从零实现 Lambert、Blinn-Phong、GGX 三条着色路径的 CPU 参考,再原样搬进 WGSL,在真实 WebGPU 设备上渲染球体并回读像素,要求 CPU 与 GPU 逐个字节对齐。
着色器写错时报错只有一行天书,定位全靠猜:本节真跑 getCompilationInfo(),拿到带行号、列号与字符偏移的诊断,并用 11.1 的 include 行映射把它还原成原始文件的文件:行:列,让编辑器能一键跳到出错的那一行源码。
uniform 结构体在 CPU 侧要按完全一致的内存布局填充,手写偏移是 bug 温床:本节从 WGSL 的 struct 反射出每个成员的偏移与整个缓冲区大小,按该布局自动打包参数,并真跑一次渲染回读像素,验证绑定确实生效,偏移对了像素才会对。
WGSL 没有 #include,但引擎要把公共函数拆成多个文件复用:本节在 createShaderModule() 之前做一层文本级 include 展开,处理递归、去重与循环检测,并真跑一次编译与像素回读,验证拼接后的模块确实可编译可用。
六个开关朴素地给出 2^6=64 个着色器变体,但特性之间有依赖,真实可达集只有 32 个。这一节按依赖枚举可达变体、用内容寻址(源码 + 开关集合的哈希)给编译结果去重,并在 headless Chromium 里把全部可达变体编译一遍,验证 0 error、非法组合会被拒。
WGSL 没有预处理器,材质却要在多种开关组合下生成不同程序。这一节实现一个只认 #ifdef / #ifndef / #else / #endif 的行级宏展开器,把一份模板裁剪成具体变体,并在 headless Chromium 里逐个编译验证 0 error。
把材质拆成「模型(参数声明)+ 实例(参数值)」两层,按 std140 算出每个参数的字节偏移并打包成 uniform buffer;再在 headless Chromium 里把它绑进管线、渲染一帧、回读中心像素,断言它就是 baseColor。
《游戏引擎实现原理》卷二《渲染引擎》全书导航页:说明本卷定位与卷一《引擎内核》的关系、本卷差异化(在 RHI 上从零实现、每节跑出真实产物、真实像素与计时结果),给出顺序通读、只读 RHI 四章、直奔帧图与后处理三条阅读路径,讲清 L1–L2 可运行性口径,并按五个部分列出 18 章 54 节的完整章节索引,另含周计划表与常见问题速查,方便读者按需定位、跳读与随时查阅。
9.2 的状态缓存砍不掉顶点缓冲的频繁切换,因为它按材质而非网格分组。这一节换一条把网格提到高位的批次键,把连续同网格的绘制并成一次实例化绘制:20 万次绘制在 64 网格场景降到 64 次,并跑出一次 draw(6,4) 画出四个彩色实例的真实像素。
写一个后端无关的状态缓存,记住当前管线、绑定组、顶点与索引缓冲,只在真正变化时下发;在 20 万次绘制的乱序与排序两种流上跑出「应用 / 跳过」两路计数,看排序能把管线切换从 199157 次砍到 512 次、又砍不掉什么,为下一节的实例化合批埋下引子。
把剔除后的可见项压进一条 32 位排序键:高 18 位决定批次归属、低 14 位决定前后顺序,再用稳定的 LSD 基数排序替代多字段比较器。在 20 万项的确定性场景上跑出三种排序的真实耗时对比,并正面校验不透明项确实近→远、透明项远→近。
把视锥剔除与 LOD 选择整体搬到 GPU:一个 compute 通道按包围球剔除、按距离选档,用原子计数器追加进对应 LOD 的实例缓冲,第二个通道写出三组 indirect 绘制参数;CPU 每帧只发三个 drawIndirect,并回读掩码与 CPU 逐对象比对验证一致。
用最长轴中位数分割把十万个 AABB 建成扁平二叉 BVH,把逐盒的线性剔除换成层次遍历;再把叶节点 AABB 与图元 AABB 摊平上传,用两个 compute 通道做两级视锥剔除,回读掩码与 CPU 的 bvhCull 逐位比对,验证完全一致。
从相机的视图投影矩阵抽出六个裁剪面,用 p-vertex 判定 AABB 与包围球的可见性;先跑通 CPU 侧剔除统计,再在 headless Chromium 里只把可见盒子按实例绘制,回读帧缓冲逐色核对被剔除的盒子一个像素都没画出来,全程附可复现代码。
把相机拆成 view / projection / viewProj 三个矩阵,从 viewProj 免费抽出六个裁剪面,再由屏幕坐标沿相机基座构造世界空间拾取射线,最后在 headless Chromium 里点一下屏幕、验证拾取命中的正是那个实例。
给场景图加上脏标记:改动一个节点只把它的子树标脏,更新时只重算被标脏的节点、并在重复标记时提前停止,再用一组确定性访问计数与逐字节像素比较,证明「脏更新」与「全量重算」产出的世界矩阵逐位一致、渲染画面也完全相同,从而让每帧只付出真正改动的那部分代价。