Lighting Technology of The Last of Us Part II by Hawar Doghramachi - SIGGRAPH 2020
Lighting Technology of The Last of Us Part II by Hawar Doghramachi - SIGGRAPH 2020
项目背景与技术挑战
游戏美术特征
- 《最后生还者 Part II》大量场景处于 阴天(Overcast) 环境,直射阳光和人工光源较少
- 因此 环境光照(Ambient Lighting) 成为渲染系统中最关键的组成部分
硬件约束
- 目标平台:PlayStation 4(已处于生命周期末期)
- 需要在 PS4 有限的性能和内存预算 下提升环境光照质量
- 渲染目标:30fps
演讲聚焦范围
- 本次演讲 仅聚焦于环境光照系统的改进(Ambient Lighting System)
- 不涉及 的内容:多重散射 BRDF 集成、太阳光和局部阴影系统的改进等
原有环境光照系统概述
支持的光照表示方式
| 对象类型 | 支持的光照方式 |
|---|---|
| 静态背景物体 | Light Mapping(光照贴图)、Irradiance(辐照度)、SH(球谐函数) |
| 动态前景物体 | 仅支持 SH Probes(球谐探针) |
核心目标
- 改善 物体光照(Object Lighting),使物体能更好地融入周围环境
- 提升光照的 方向性(Directionality) 和 阴影质量(Shadowing)
Dominant Light 系统改进(方向性提升)
原有方案
- 从 SH 环境光照 中提取 主导光(Dominant Light),得到:
- 环境基础色(Ambient Base Color)
- 主导光颜色(Dominant Light Color)
- 主导光方向(Dominant Light Direction)
- 对于 光照贴图和 Light Mapping:提取在 逐像素(Per-Pixel) 层面完成(烘焙时)
- 对于 SH Probes(动态物体):提取仅在 CPU 端逐物体(Per-Object) 做一次
- 导致每个物体只有 一个统一的主导光方向
- 结果看起来 扁平、缺乏立体感
改进方案:GPU 端逐像素提取 + 半球遮罩(Hemisphere Masking)
核心思路
- 将主导光提取移到 GPU 端逐像素执行
- 对 SH 环境光做 半球遮罩(Masking):屏蔽掉 背向表面法线 的半球方向上的辐照度
为什么这样做有意义?
- 从物理概念上讲,来自 表面法线背面 的光线不可能照亮当前表面
- 遮罩后,得到的主导光颜色和方向会 随表面法线变化(Per-Pixel Varying),产生更丰富的方向性
数学实现
- 遮罩通过 SH 乘积(SH Product) 实现:将 辐照度 SH(Radiance SH) 与一个 朝向表面法线的余弦瓣(Cosine Lobe) 做乘积
- 使用 三重积张量(Triple Product Tensors) 来高效计算
其中 为法线 朝向的半球, 为 SH 编码的辐照度
视觉效果对比
- 无遮罩:角色看起来扁平,有"游戏感"的不自然外观
- 有遮罩:角色呈现更强的方向性,更符合概念美术(Concept Art)的目标
Dominant Light 的阴影系统改进
原有方案:手动放置 Ambient Capsule & Volume
- 使用 手动放置的环境胶囊体(Ambient Capsules)和体积(Volumes) 来为主导光产生阴影
- 仅用于 动态物体在静态环境上投射阴影
- 不支持 动态物体之间互相投影
改进一:动态物体之间的阴影投射
问题
- 需要让 Ambient Capsule/Volume 阴影也能投射到 其他动态物体 上
- 但必须避免 自阴影(Self-Shadowing)(因为胶囊体/体积是粗略近似,自阴影会产生严重 Artifact)
解决方案:Object ID 过滤
- 在 Depth Pass 中,动态物体将自身的 Object ID 写入一个 Render Target
- 在 全屏环境阴影 Pass 中,将 Object ID 映射到对应的 Capsule/Volume ID
- 当处理的 Capsule/Volume 与当前像素所属的物体 是同一个物体 时,跳过阴影计算
- 这样实现了 物体间互投阴影,但不自阴影
改进二:屏幕空间锥形追踪(Screen Space Cone Tracing, SSCT)
动机
- Capsule/Volume 方案无法实现高质量的 自阴影
- 原有的 SSAO(屏幕空间环境光遮蔽) 是非方向性的,效果有限
核心思路
将深度缓冲视为 高度场(Height Field),沿主导光方向做 锥形追踪(Cone Tracing)
算法流程
-
深度缓冲解释:将主摄像机的 Depth Buffer 视为一个 高度场(从垂直于主摄像机方向的视角来看)
- 这是一个近似,因为深度缓冲可能不连续,但对实际效果足够好
-
逐着色点追踪:对每个着色点,沿 主导光方向 追踪一个 可见性锥体(Visibility Cone)
-
步进采样:沿锥体方向步进(默认 4 个采样步)
- 步进位置按特定分布排列(类似图示中的蓝色圆圈)
- 在步进过程中持续追踪高度场与可见性锥体的 最大重叠量(Maximum Overlap)
-
Jittering(抖动)策略:
- 在 水平面 上对锥体方向进行抖动,将锥体切片转化为体积采样
- 使用 Halton 序列 + 蓝噪声 的组合,每 4 帧重复
- 配合渲染管线末端的 TAA(时域抗锯齿) 进行降噪
-
可见性计算:将最大重叠量转换为 连续可见性值
-
软阴影控制:通过调整 锥体角度(Cone Angle) 控制阴影的软硬程度
扩展用途
- 同样的 SSCT 技术以 不同的锥角 被复用于增强 太阳光和局部光源的阴影
最终阴影合成
双重环境阴影项
在全分辨率的 SSAO Pass 中,同时计算两个阴影项:
| 阴影项 | 作用对象 |
|---|---|
| SSAO | 无方向性的环境基础光(Ambient Base) |
| SSCT | 有方向性的主导光(Dominant Light) |
合成方式
- SSAO 和 SSCT 的结果通过 min 运算符 合成
应用范围
- SSCT 仅用于动态物体的自阴影(因为静态物体已有烘焙方案)
性能数据(Base PS4, 1080p)
| 配置 | 耗时 |
|---|---|
| 仅 SSAO(无 SSCT) | ≈ 1.15 ms |
| SSAO + SSCT | ≈ 1.28 ms |
- SSCT 额外开销仅约 0.13 ms,非常经济
视觉效果
- 仅 SSAO:阴影缺乏方向感,整体较为平坦
- SSAO + SSCT:产生柔和的方向性阴影,无明显走样(Aliasing-Free),这对实时环境光照至关重要
- 默认 4 步采样已能产生令人满意的软阴影效果
关键技术总结
| 技术 | 解决的问题 | 核心方法 |
|---|---|---|
| 半球遮罩的 Dominant Light 提取 | 动态物体光照方向性不足 | GPU 端逐像素 SH 乘积遮罩 |
| Object ID 过滤的 Capsule/Volume Shadow | 动态物体间无法互投阴影 | Depth Pass 写 Object ID,全屏 Pass 跳过自身 |
| SSCT(屏幕空间锥形追踪) | 动态物体缺乏方向性自阴影 | 深度缓冲视为高度场,沿主导光方向锥形追踪 |
体积探针光照系统(Volumetric Probe Lighting System)
环境探针(Ambient Probes)基础设施
探针布局
- 由美术人员 手动放置 或使用 程序化工具 生成
- 不需要遵循规则网格分布,可以自由分布
- 每个可游玩区域最多支持 64,000 个环境探针
- 使用 KD 树(KD-Tree) 进行空间查找
探针数据存储格式
每个探针仅占 40 字节,结构如下:
| 数据内容 | 存储方式 | 说明 |
|---|---|---|
| SH 系数(9 个 RGB 系数) | 每系数 1 字节,共 27 字节 | 量化后的球谐系数 |
| 浮点缩放因子(Scale) | 用于解压 SH 系数 | 压缩精度恢复 |
| 太阳阴影值(Sun Shadow) | 1 字节 | 烘焙的太阳阴影信息 |
| 遮蔽数据(Occlusion Data) | 8 字节 | 用于后续遮蔽计算 |
旧系统的问题
原有插值方式
- 在 CPU 端 进行插值,通常取物体 中心点 附近最近的 10 个探针 进行加权
- 每个物体只有一个插值结果
核心缺陷
- 空间频率不足:一个物体只有一套光照数据,无法准确捕捉周围光照环境的空间变化
- 大物体上半部分和下半部分可能处于完全不同的光照环境中,但呈现的却是同一套光照
新体积探针系统:逐角插值 + 3D 纹理图集
核心改进思路
- 将插值从 CPU 端移到 GPU 端
- 不再只对物体中心做一次插值,而是对物体 OBB(有向包围盒)的 8 个角点 分别做独立插值
- 将 8 个角点的 SH 结果存储到一个 3D 纹理图集(3D Texture Atlas) 中
- 在像素着色阶段,通过 三线性插值(Trilinear Interpolation) 从这个 2×2×2 的纹理块中采样
逐角插值的加权算法
对每个 OBB 角点 (),搜索 KD 树中落入 搜索半径(通常为 6 米)内的环境探针,然后对每个探针计算权重:
权重因素一:距离权重
其中 是从角点到探针的 采样向量。探针越近,权重越高。
权重因素二:角度权重
其中 是 采样向量 与 从包围盒中心指向该角点的向量 之间的夹角。
- 夹角越小 → 权重越高
- 目的:防止小物体的 8 个角点都插值到相同的一组探针(因为小物体角点距离很近)
- 当包围盒变大时,角度权重的影响自然减弱
最终插值
- 选取 权重最高的 10 个探针 进行加权插值
- 将结果写入 3D 纹理图集中对应的 2×2×2 纹理块
3D 纹理图集的物理存储
| 纹理 | 格式 | 内容 |
|---|---|---|
| 7 张纹理 | RGBA8 | 存储 9 个 RGB 量化 SH 系数(27 个分量分布在 7 张 RGBA 纹理中) |
| 1 张纹理 | R16 | 存储 SH 系数的压缩缩放因子 |
为什么只用 8 个角点而非更密集的采样?
- 内存和性能限制:PS4 预算紧张
- 由于可操作物体(Props)通常 尺寸有限,8 个角点的分辨率 足以满足质量需求
- 实际效果验证表明 2×2×2 的分辨率在大多数情况下够用
像素着色时的采样流程
- 从像素的 世界空间位置 出发
- 利用 物体 ID(Object ID) 映射到纹理图集中对应的偏移位置
- 转换为 纹理坐标
- 使用 硬件三线性插值(Hardware Trilinear Interpolation) 采样 2×2×2 纹理块
- 将纹理坐标钳制(Clamp) 在 2×2×2 块范围内,避免跨块插值伪影
为什么需要 Clamp?
- 有时 OBB 比实际物体略小(例如有 顶点动画的植被,风吹时部分顶点会超出 OBB 范围)
- Clamp 确保超出部分不会采样到其他物体的光照数据
缓存与性能优化
异步计算(Async Compute)
- SH 系数的插值与写入纹理图集操作在 Async Compute 上执行
- 安排在 渲染管线非常早期 的阶段
- 原因:该步骤 不依赖 GPU 上其他计算结果(如深度缓冲等),可以与其他工作并行
缓存策略
- KD 树查找结果 和 纹理图集条目 被缓存
- 仅当物体移动或改变尺寸时 才更新缓存
- 大多数可操作物体 大部分时间不移动,因此缓存命中率很高
缓存存储结构
| 阶段 | 存储位置 | 说明 |
|---|---|---|
| 物体首次可见(Stream In) | 写入 线性缓冲区(Linear Buffer) | 缓存探针插值结果 |
| 每帧(无需更新时) | 从缓冲区 复制 到纹理图集 | 保证采样时数据的 缓存局部性(Cache Coherence) |
| 物体不可见(Stream Out) | 驱逐(Evict) 缓存条目 | 释放空间 |
为什么要每帧复制而不直接从缓冲区采样?
- 纹理图集中的数据布局是 紧凑连续 的,采样时 缓存性能更好
- 如果直接从线性缓冲区采样,数据可能在内存中分散,导致大量 缓存未命中(Cache Miss)
视觉效果对比
旧系统(单探针插值)
- 角色 Ellie 没有任何空间光照变化
- 上半身 过亮,下半身 过暗
- 整体看起来 扁平、不自然
新体积探针系统
- 角色 更好地融入环境
- 上半身 适当变暗,下半身 适当变亮
- 呈现出 自然的光照渐变
特殊处理:可破坏物体(Destructible Objects)
问题描述
- 《最后生还者 Part II》有大量 可破坏物体(如可打碎的玻璃)
- 出于性能考虑,整个可破坏物体作为 单次 Draw Call 渲染
- 破坏发生后,碎片可能 飞散到很远的距离
- 这导致整体 OBB 急剧变大,原本的光照变得完全不准确
解决方案:Per-Fragment OBB
核心思路
- 不使用整个物体的 OBB,而是使用 每个碎片自己的 OBB
- 每个碎片的 OBB 对应其 绑定的刚体(Rigid Body) 的包围盒(即骨骼绑定的包围盒)
实现方式
- 仍然以 单次 Draw Call 渲染整个物体
- 在写入纹理图集时:用 Object ID + 骨骼索引(Bone Index) 作为偏移,为每个碎片分配独立的纹理图集位置
- 在像素着色采样时:同样用 Object ID + Bone Index 定位到正确的纹理块
缓存压力问题
- 该系统为可破坏玻璃面板大量使用
- 虽然支持最多 64,000 个缓存条目,但有时仍会 耗尽缓存空间
- 耗尽时的应对策略:
- 每帧重新计算 被驱逐的条目(性能代价高)
- 对于未纳入黄金版本审核的玻璃面板,使用 旧的探针系统 作为回退
视觉效果对比
旧系统
- 整个木板容器由 单个探针 照亮
- 场景中有两个木板容器:一个在建筑内部,一个在外部
- 射击容器后释放的红球:
- 左侧明亮红球 → 光照完全错误(使用了建筑外部的光照)
- 靠近窗户的球 → 没有任何空间光照变化
新系统
- 每个碎片/球有 独立的光照
- 左侧红球 → 正确的室内光照
- 靠近窗户的球 → 呈现自然的空间光照渐变(靠窗更亮,远窗更暗)
特殊处理:网格聚类(Mesh Clustering)
问题描述
什么是 Mesh Clustering?
- 为了优化性能,将 同一 Mesh 的多个实例 在一定半径内 合并(Cluster) 为单次 Draw Call
- 例如:多个小草丛 Patch 合并为一个大的草丛集群
产生的问题
- 合并后整个集群的 OBB 可能 非常大
- 大 OBB 中 2×2×2 的光照分辨率变得 完全不够用
- 导致光照失真
解决方案:Per-Instance OBB
核心思路
- 在有问题的情况下,使用集群内 每个实例各自的 OBB 而非整个集群的 OBB
- 仍然以 单次 Draw Call 渲染整个集群
实现方式
- 与可破坏物体类似:用 Object ID + Instance ID 作为偏移
- 写入纹理图集和像素采样时都通过该偏移定位
为什么不全局启用?
- 缓存空间有限:每个实例占用独立的缓存条目,全局启用会迅速耗尽 64,000 条目的上限
- 仅在 光照质量明显有问题的区域 启用
视觉效果对比
仅使用集群 OBB
- 大片草地使用巨大的集群包围盒计算光照
- 部分区域(例如两块混凝土块下方的草地)光照明显偏亮或偏暗,不符合预期
启用 Per-Instance 光照
- 每个小草丛使用自己的包围盒进行光照计算
- 光照与周围环境 更匹配,过渡自然
总结:体积探针系统的关键设计决策
| 设计维度 | 选择 | 理由 |
|---|---|---|
| 插值位置 | OBB 8 角点 | 内存/性能平衡 |
| 存储方式 | 3D 纹理图集 + 硬件三线性插值 | 高效采样 |
| 执行位置 | GPU Async Compute | 不阻塞主渲染管线 |
| 缓存策略 | 仅移动/变形时更新 | 大幅减少计算量 |
| 特殊物体处理 | Object ID + Bone/Instance 偏移 | 保持单次 Draw Call 的性能优势 |
动态绳索的特殊光照处理
问题与动机
- 动态绳索(Dynamic Ropes)的 OBB(有向包围盒)不适合 前面介绍的体积探针光照方案
- 绳索由多个 节点(Nodes) 分隔成若干段,几何形态细长且不规则,无法用一个 OBB 有效覆盖
绳索光照方案
插值阶段
- 在每个节点的 中心位置 对周围环境探针进行插值,获得该节点处的 SH 光照数据
蒙皮阶段计算光照
- 在 蒙皮(Skinning) 过程中,利用已插值的探针数据和节点参数,计算每个顶点的 探针光照结果,并 存储到顶点数据 中
- 在后续的 像素着色器 中,直接读取插值后的顶点光照结果
简化处理
- 绳索 不需要强方向性光照,因此 不提取 Dominant Light(主导光)
- 这也节省了顶点内存开销
- 绳索在游戏中 出现频率较低,因此可以承受在蒙皮阶段做额外光照计算的开销
- 当前蒙皮和探针插值运行在 Async Compute 管线上
效果对比
| 状态 | 表现 |
|---|---|
| 旧系统(单个插值结果) | 整条绳索 被一组统一的探针照亮,明显突兀,无法融入环境 |
| 新系统(逐节点插值) | 绳索呈现 自然的光照渐变,与周围环境良好融合 |
SH 振铃伪影去除(Removing Ringing Artifacts)
问题描述
- SH 振铃(Ringing) 是球谐函数的固有问题:截断高阶项后,重建信号会出现 过冲和负值
- 在渲染中表现为 金属表面上的烧焦亮斑 或 不自然的暗斑
静态去振铃(Static Deringing)
烘焙时处理
- 在 烘焙阶段 对每个环境探针执行 迭代式去振铃(Iterative Deringing)
- 对每个探针找到并应用 最不激进的窗函数(Least Aggressive Window)
- 保证重建后的 SH 环境 不产生负辐照度值
- 同时尽可能保留原始光照信息
效果
- 未去振铃:调试可视化中可见暗斑等伪影
- 去振铃后:不受振铃影响的探针 完全不被改变,仅修正有问题的探针
动态去振铃(Dynamic Deringing)
问题场景
- 振铃伪影主要出现在 背向主导光方向的表面区域
- 尤其在 金属材质 上格外明显(金属表面直接反射环境光,负值导致"金属化"外观)
运行时处理方法
- 通过前述的 半球遮罩(Hemisphere Masking) 从 SH 环境中提取 主导光方向
- 当 表面法线偏离主导光方向 时,降低主导光提取量(Extraction Amount)
- 这样在背光面减弱方向性提取,避免产生负值导致的伪影
效果对比
| 状态 | 表现 |
|---|---|
| 无动态去振铃 | 角色肩部和三头肌区域呈现不自然的 金属质感 |
| 有动态去振铃 | 消除金属化外观,同时 保留光照方向性 |
环境探针遮蔽(Ambient Probe Occlusion)
核心问题
- 传统探针插值仅基于距离和角度,缺少遮蔽信息(Occlusion)
- 当探针位于墙壁另一侧时,仍会被纳入插值 → 产生 漏光(Light Leaking)
遮蔽数据的烘焙
烘焙流程
- 从每个环境探针向周围环境发射 数百条射线
- 将最近碰撞距离 投影到一个虚拟立方体(Virtual Cube) 的 6 个面 上
- 使用 非负最小二乘法(Non-Negatively Constrained Least Squares) 进行蒙特卡洛式投影
立方体朝向优化
- 立方体不构成正交基,直接投影精度不够
- 旋转立方体 使其 一个面朝向最大遮蔽方向
- 再绕该方向旋转,使其 尽可能对齐第二大遮蔽方向
- 这种对齐显著 提升遮蔽精度
调试可视化
- 将虚拟遮蔽立方体的 6 个面 映射到调试球体 上显示
- 面越暗 → 该方向遮蔽距离越近(周围有遮挡物)
遮蔽数据存储(仅 8 字节!)
| 数据 | 存储 | 说明 |
|---|---|---|
| 每面 1-2 个深度值 | 每面 1 字节 | 假设最大距离 6 米 |
| 法线方向 | 1 字节 | 转换为 球面 Fibonacci 索引 |
| 切线方向 | 1 字节 | 存储为绕法线的 角度 |
整个遮蔽数据紧凑到 仅 8 字节/探针,正好嵌入前述 40 字节探针数据结构中
运行时遮蔽采样
采样流程
给定插值点 (例如 OBB 角点)和环境探针及其关联的遮蔽立方体:
- 计算 采样向量 (从探针指向插值点)
- 计算 遮蔽深度:对 6 个面的深度值做 加权求和
其中权重 由 立方体轴 与采样向量 的角度关系 决定
- 将 与采样向量长度 比较 来判定遮蔽
- 使用 Smoothstep(平滑 Hermite 插值) 替代二值比较,避免硬边过渡
精度与美术调整
- 高度压缩的遮蔽数据 无法精确捕捉复杂的高频遮蔽环境
- 美术人员可通过 遮蔽缩放(Scale)和偏移(Bias) 参数手动调整,消除伪影
效果对比
| 状态 | 表现 |
|---|---|
| 无探针遮蔽 | 墙壁两侧的明暗探针混合插值,物体产生 漏光/穿帮 |
| 有探针遮蔽 | 遮蔽正确隔离不同空间的光照,物体 自然融入环境 |
混合探针光照系统(Hybrid Probe Lighting System)
动机
- 顶点光照(Vertex Lighting) 内存开销大
- 光照贴图(Light Mapping) 存在分辨率问题
- 美术希望 静态背景物体也能使用探针光照
- 但对于大型静态物体,体积探针的 2×2×2 空间分辨率不够
核心观察:亮度与色度分离
在 阴天(Overcast) 光照条件下,光照环境的 色度(Chrominance)变化远小于亮度(Luminance)变化
混合方案
- 仅在逐顶点层面存储 光照亮度信息(Luminance)
- 运行时将顶点亮度与 体积探针系统提供的色度(Chrominance) 组合
内存收益
| 系统 | 每顶点存储 | 内容 |
|---|---|---|
| 原有顶点光照 | ~16 字节 | Ambient Base Color + Dominant Light Color + Direction |
| 混合探针光照 | 仅 4 字节 | Ambient Base Luminance + Dominant Light Luminance |
- 主导光方向 不再逐顶点存储,而是从体积探针系统的 SH 半球遮罩 中逐像素提取
- 但烘焙时仍需 逐顶点确定主导光方向(因为使用逐物体的方向会有质量退化)
效果评价
| 方案 | 质量 | 内存 |
|---|---|---|
| 纯顶点光照 | 最好 | 高,不可接受 |
| 纯体积探针 | 大物体质量不足 | 低 |
| 混合探针光照 | 介于两者之间,视觉效果良好 | 可接受 |
- 在 阴天场景(游戏的主要光照条件)下,色度变化小,混合方案的效果 非常接近纯顶点光照
- 演示中使用的极端色度变化场景是为了对比展示
总结与反思
技术背景
- 《最后生还者 Part II》开发于 PS4 生命周期末期
- 当时 动态光线追踪不可用,而游戏大量阴天场景对环境光照系统提出极高要求
各项改进总结
| 改进点 | 核心技术 | 效果 |
|---|---|---|
| Dominant Light 方向性 | GPU 逐像素 SH 半球遮罩提取 | 角色光照更立体 |
| Dominant Light 阴影 | 动态物体间 Capsule 投影 | 角色互投影 |
| 体积探针系统 | OBB 8 角点插值 + 3D 纹理图集 | 空间光照变化更丰富 |
| 绳索光照 | 逐节点插值 + 蒙皮阶段计算 | 绳索融入环境 |
| SH 去振铃 | 静态窗函数 + 动态方向衰减 | 消除金属化伪影 |
| 探针遮蔽 | 8 字节遮蔽立方体 | 消除漏光 |
| 混合探针光照 | 亮度/色度分离存储 | 大幅降低内存 |
核心成就
- 在 不打破帧率和内存预算 的前提下,实现了令人信服且一致的光照效果
- 前景动态物体能够 良好融入周围环境,不再显得突兀
- 所有优化都充分利用了 游戏特定的光照条件(大量阴天场景),将有限的硬件资源用在刀刃上