Rendering the Hellscape of Doom Eternal
Rendering the Hellscape of Doom Eternal - YouTube
idTech 7 引擎的核心变化(相较于 idTech 6)
- 渲染管线:从部分延迟渲染(Partially Deferred)切换为 完全前向渲染(Fully Forward Rendering) ,在测试中略快
- 着色器数量极少:引擎依然保持极少量 Shader 的设计哲学,这在 迭代时间 和 运行性能 上都是巨大优势
- 在 Vulkan 下仅约 500 个不同的 Pipeline
- 关卡规模更大、世界细节更多:为此 Streaming 系统 做了大幅改进和扩展
- 全面使用低级别图形 API:所有平台及工具链均使用 Vulkan ,彻底移除了 OpenGL
- 性能目标:在与 Doom 2016 相同分辨率下,目标平台维持 稳定 60 FPS
光源分箱系统(Light Binning)
背景与动机:为什么需要新方案
idTech 6(Doom 2016)的做法
- 使用 Clustered Binning(聚簇分箱) :将视锥体划分为 3D 网格(Cluster),每个 Cluster 记录其中包含的光源和 Decal
Doom Eternal 面临的新挑战
- 场景规模大幅增加:大量 小光源 和 Decal 聚集在同一个大 Cluster 中,造成严重 overdraw
- 敌人拥有光照骨骼绑定(Light Rigs) ,加上更多的 投射物(Projectiles) ,导致屏幕上小光源数量暴增
- 美术希望放置 更多光源和载具 以提高画面品质
- 希望将 Binning 工作 从 CPU 迁移到 GPU ,降低 CPU 负载
数据规模示例(莫斯科关卡某场景)
- 约 1100 个 Decal + 150 个光源
- Clustered Binning 下大量区域呈红色(> 20 个光源/Decal 需要计算)
- 新方案下 overdraw 显著降低
混合分箱方案:Tile + Cluster 混合(Hybrid Binning)
核心思路
结合 Tile Binning(瓦片分箱) 和 Cluster Binning(聚簇分箱) 的优点,对每个片元选择列表条目更少的那个来使用。
灵感来源
- 受 Michael Trost 在 SIGGRAPH 2017 发表的 "Improved Culling for Tiled and Clustered Rendering" 启发
该方案对 Doom Eternal 的不适用之处及解决
- 原方案使用硬件光栅化做 Tile Binning,但 Doom Eternal 场景中有 数千个光源/Decal ,大量 Stencil 状态切换 使硬件光栅化效率低下
- GPU Binning 的预算仅 约 500 微秒(Xbox One) ,非常紧张
解决方案:自定义软件光栅化器(Compute Shader 实现)
- 仅光栅化 六面体(Hexahedra) 形状(盒子、金字塔等),不支持更复杂形状
- 理由:后续光照处理阶段还有 early rejection,更精确的形状收益递减
- 光栅化分辨率 很低 ,但必须是 保守光栅化(Conservative) ,防止光源被错误剔除
- 边界情况极多——"美术总能找到你遗漏的那些"
- 远处平面法线等精度问题使用 反向浮点深度(Reverse Floating Point Depth) 解决
四阶段光栅化管线(Compute Shader)
整个软件光栅化器由 四个 Compute Shader 阶段 组成:
阶段一:Setup & Cull(设置与剔除)
- 每个六面体分配 一个线程
- 近平面裁剪(Near Plane Clipping) :裁剪靠近或位于相机后方的面
- 由于不使用扫描线光栅化,其他裁剪平面可忽略
- 输出一个 紧凑打包的结构体 ,包含顶点及边/面索引
- 分类每个顶点和边的 正面/背面朝向(Front/Back Facing) ,后续用于确定光栅化时的深度范围
- 若六面体被裁剪产生新面,光栅化器可检测到 相机位于六面体内部(此时所有面均为背面朝向)
- 计算 屏幕空间 Tile 包围盒 ,为粗光栅化阶段发射工作
阶段二:Coarse Raster(粗光栅化)
关键优化——线程利用率问题:
- 朴素方法:每个 Tile 一个线程(8×8 线程组)→ 大量小六面体时线程利用率极差(最多 63 个线程空闲)→ 某些场景光栅化耗时数毫秒
- 实际方案:为每个六面体的 每个可能覆盖的 Tile 发射 一个线程 ,通过列表收集,再用 Indirect Dispatch 消费
- 例:64 个各覆盖 1 个 Tile 的小六面体仍能填满一个线程组
具体工作:
- 每个线程处理一个六面体的一个潜在覆盖 Tile( 256×256 像素区域 )
- 检测该 Tile 区域是否与六面体相交
- 若相交,同时进行 Cluster Binning :屏幕尺寸同为 256×256 像素、深度方向 24 层 (沿用 Doom 2016)
- 对每个相交的 Cluster,在 Cluster Bit Field 中置位
- 此阶段已比 idTech 6 的 CPU 路径更精确(CPU 使用的是近似求交)
- 产出 Fine Raster 阶段的工作列表(每个 Coarse Tile 有自己的工作列表,允许更紧致的包围)
阶段三:Fine Raster(细光栅化)
- 每个 Fine Tile 对应一个线程( 32×32 像素 区域)
- 判断六面体是否与该 Fine Tile 相交
- 由于所有面一起处理,可精确确定整个六面体在该 Tile 范围的 Min/Max 深度值
- 利用帧早期 保守下采样的深度缓冲 (每个 32×32 Tile 的 min/max 值),做 Min-Max 深度交叉测试
- 若相交,在 Coarse Tile Bit Field 中标记该六面体可见
- 对 点光源 额外做 近似球体 vs. Tile 视锥体 求交,进一步收紧
阶段四:Resolve(Bit Field → 索引列表)
- 光栅化完成后,将 Bit Field 转换为 光源/Decal 索引列表
- 因为在 Fragment Shader 中逐 bit 扫描 Bit Field 效率太低
- 每个 Tile 使用一个 64 线程的线程组 ,并行检查 64 bit
- 使用 Subgroup Operations(子组操作) 紧凑输出索引列表
- 最后由线程组中的一个线程写入 最终光源计数
Tile vs. Cluster 的运行时选择
深度不连续性问题
- 仅使用 Tile 列表时,在 深度跳变剧烈 的区域(如前景牙齿与远景背景同在一个 Tile),min-max 深度范围巨大,导致大量光源被错误地认为相交
解决方案:逐片元选择
- 对每个 不透明片元(Opaque Fragment) 同时检查 Tile 列表 和 Cluster 列表 ,选择 条目更少的那个
- 效果:整体需要考虑的光源数显著减少
可视化
- 绿色像素:使用 Tile 列表
- 蓝色像素:使用 Cluster 列表
标量化优化(Scalarization for AMD GPUs)
背景(Doom 2016 的优化)
- AMD 主机 GPU 拥有 标量单元(Scalar Unit) ,可用来读取光源/Decal 参数数据
- 前提:线程组内 所有线程必须处理相同的光源/Decal 列表
- idTech 6 的做法:检查线程组内所有线程是否使用同一个 Cluster → 若一致则标量化读取
Doom Eternal 的新问题
- Tile 数量远多于 Cluster 数量 → 直接按 Tile ID 检测一致性时,大量线程组不满足标量化条件 (可视化中红色区域大增)
解决方案:基于哈希的一致性检测
- 在 Bit Field Resolve 阶段为每个 Tile 计算光源列表的哈希值(以光源数量为初始值)
- Fragment Shader 中不再检查 Tile ID 一致性,而是检查线程组内所有线程访问的列表 哈希值是否相同
- 效果:大面积屏幕区域共享相同哈希 → 绝大多数线程组恢复标量化
- 额外好处:即使线程组内有的线程用 Tile 列表、有的用 Cluster 列表,只要 哈希相同 就能标量化
GPU 利用率优化与免费的可见性查询
问题
- 光栅化分辨率很低,每个线程运行周期长(需测试六面体的每个面、边、顶点) → GPU 并未被完全占满
异步计算(Async Compute)重叠
- 与光栅化并行执行 上一帧的后处理 ,提升 GPU 利用率
免费的可见性查询(Occlusion Query)
- 发现增加更多六面体对光栅化总耗时 几乎没有影响
- 游戏逻辑团队需要知道某些物体是否可见,以关闭昂贵的 CPU 计算
- 做法:将这些物体的包围盒一并送入 Binning 光栅化器
- 在 Fine Raster 阶段不做相交测试,而是检查六面体是否在 保守下采样深度缓冲的最大值前方
- 若可见,在 Bit Field 中置位,CPU 下一帧读取
- 可支持 数千个可见性查询 ,性能开销 几乎为零
关键数据总结
| 指标 | 数值 |
|---|---|
| Vulkan Pipeline 数量 | ~500 |
| 示例场景 Decal 数量 | ~1100 |
| 示例场景光源数量 | ~150 |
| GPU Binning 预算 (Xbox One) | ~500 μs |
| Cluster 屏幕尺寸 | 256×256 像素 |
| Cluster 深度层数 | 24 |
| Fine Tile 尺寸 | 32×32 像素 |
| Resolve 线程组大小 | 64 线程 |
几何贴花(Geometry Decals)
动机与挑战
- 几何贴花 是美术人员作为网格一部分来制作的小型贴花,目的是 减少纹理内存消耗 同时 提升画面精细度
- 核心约束:
- 额外帧耗时必须 极低
- 引擎使用 前向渲染(Forward Rendering) ,无法像延迟渲染那样直接在 G-Buffer 上叠加贴花
- 即使有 G-Buffer,逐像素混合也可能太慢
效果对比
- 关闭几何贴花时,资产表面缺少小细节
- 开启后,在 不增大基础纹理尺寸 的前提下,添加了大量微小表面细节
实现方案:投影贴花与网格贴花的混合体
核心思想
- 通过网格的 UV 坐标和顶点位置 计算出从 世界空间到纹理空间的投影矩阵
- 由于世界空间到纹理空间对每个实例都不同,实际 存储的是物体空间到纹理空间的矩阵(Object → Texture Space) ,写入磁盘
- 运行时将其与物体的 模型矩阵(Model Matrix) 相乘,得到 世界空间 → 纹理空间 的变换
- 这意味着 每个实例 都需要为其每个投影分配独立内存
渲染管线流程
阶段一:深度通过后写入索引缓冲
- 在 Depth Pass 之后 ,将贴花索引渲染到一个 R8 缓冲区 中
- 深度比较模式设为 Equal ,确保被不透明几何体遮挡的贴花不会被绘制
- 为避免 Z-Fighting ,施加微小的 深度偏移(Depth Bias)
- 限制条件:贴花必须 与其投影目标网格共面(Coplanar) ,否则索引分配会出错
- 性能极高:
- 只需写入 一个 R8 通道
- 几何体三角形很少且屏幕覆盖率低
- Xbox One 上通常仅需约 50 微秒
阶段二:前向着色阶段读取并应用
- 在 前向着色片元 Pass 中,从 R8 缓冲区读取几何贴花索引
- 用该索引查找绑定在当前实例上的 投影矩阵列表 ,从而 重建纹理坐标
- 因为贴花在 Fragment Shader 中应用,可以对底层材质做 任意混合(Arbitrary Blending)
性能与限制
| 方面 | 详情 |
|---|---|
| 性能 | 渲染时间几乎为零("virtually free"),典型场景有 数千个 几何贴花在屏幕上 |
| 索引上限 | R8 缓冲 → 每个网格最多 254 个投影 |
| 投影共享 | 若纹理空间连续且贴花几何体是平面的,多个贴花三角形可 共享同一投影 |
| 弯曲几何 | 弯曲的贴花几何体会产生 大量额外投影 ,快速耗尽上限 |
| 内存池 | 全局分配 100 万个贴花 的池,对应约 32 MB 内存 |
| 应用顺序 | 当前实现中几何贴花 总是最先应用 |
| 动画网格 | 需要 实时重新计算 物体空间 → 纹理空间的投影矩阵,通过 Compute Shader 完成;由于动画投影通常很少,速度很快 |
几何缓存(Geometry Caches)
概述
- 几何缓存 是预录制的动画数据,从 Alembic Cache 编译而来
- 动画数据在运行时 从磁盘流式加载(Streaming) ,因为一次性载入内存数据量太大
压缩与编码改进
帧间预测压缩
- 引入 预测帧(Prediction Frames) 和 分层 B 帧(Hierarchical B-Frames)
- 运动预测支持 前向和后向 两个方向
- 最终比特流使用 Kraken 压缩算法
数据共享
- 同一几何缓存的 多个实例 可以 共享内存中的流式数据块
支持的动画属性
- 不仅支持 位置(Positions) 动画
- 还支持 颜色(Colors) 和 UV 动画,用于特效
自动蒙皮压缩(Automatic Skinning)
原理
- 基于 Lattice & Le Carbone 在 SIGGRAPH 2010 提出的方法
- 算法 自动生成 蒙皮动画,包含:
- 基础姿态(Base Pose)
- 骨骼分配(Bone Assignment)
- 每帧的骨骼矩阵(Bone Matrices per Frame)
整合到几何缓存框架
- 对骨骼矩阵进行 量化(Quantize)
- 应用与其他数据流相同的 预测 + 压缩 流程
效果对比
- 常规顶点动画:9.3 MB 磁盘空间
- 自动蒙皮(63 根骨骼):仅 2.8 MB
局限性
- 自动蒙皮并非对所有动画都效果良好
- 例如网格两部分需要 精确对齐 时,可能出现 可见裂缝(Cracks)
- 此类情况仍回退到 常规顶点动画
切线帧的流式传输与压缩
问题
- 为保证质量,每帧都需要 预计算切线帧(Tangent Frame) 并流式传输
- 朴素存储(两个三分量向量)数据率过高
压缩编码方案
| 数据 | 编码方式 | 字节数 |
|---|---|---|
| 法线(Normal) | 八面体法线编码(Octahedral Normal Encoding) | 2 字节 |
| 切线旋转角 | 法线周围的旋转角度 | 1 字节 |
- 经过 行程编码(Hit/Run Encoding) 后,2 字节对顶点法线已足够
切线重建算法
步骤一:求确定性正交向量
- 不能简单地用法线与 X 轴叉积——当法线接近 X 轴时会产生 奇异性(Singularities)
- 改为根据法线的 X 和 Z 分量的绝对值 选择正交向量:
步骤二:用罗德里格斯旋转公式重建切线
- 将正交向量绕法线旋转指定角度
- 罗德里格斯公式中的最后一项 因为 向量正交 而 消去(点积为零)
材质混合(Material Blending)
用途与工作流
- 材质混合是 为世界添加细节的第一步 ,先于 Decal Pass 和关卡装饰
- 两大应用场景:
- 道具构建阶段:例如给将在多处复用的模型添加 污垢层(Grime Layer)
- 关卡构建阶段:针对特定环境添加 雪、苔藓 等效果到特定模型实例上
实现细节
混合内容
- 混合的是 完整材质栈(Full Material Stack) ,包括:
- Albedo(基础色)
- Normal(法线)
- Specular(高光)
- Smoothness(光滑度)
- Emissive(自发光)
混合权重来源
| 来源 | 说明 |
|---|---|
| 顶点绘制(Vertex Painting) | 最常用方式 |
| 专用纹理贴图 | 当曲面细分级别与顶点绘制粒度不匹配时使用,多见于 角色和有机资产 |
| 材质高度图(Material Height Maps) 运行时调制 | 为原本可能很粗糙的混合权重数据添加 高频细节 ,并尊重底层材质的 物理特征 |
工具与约束
- 混合过程使用 专用内部工具 完成
- 每个资产最多 4 种材质
- 每个三角形最多 2 种材质
LOD 与顶点绘制的挑战
问题
- LOD 生成过程中的 顶点简化 可能 丢弃包含重要绘制信息的顶点 ,导致明显的 LOD 切换跳变(Popping)
解决方案
- LOD 0(最高精度级别)的所有绘制实例 共享同一份顶点数据
- 低精度 LOD 则 独立存储 绘制信息(made unique with painting)
- 由于每个资产的 绘制排列组合有限 ,加上大量使用 Decal 进行位置特定细节化,内存开销可控
效果示例
- 环境场景:基础材质上叠加污垢层后,表面质感大幅提升
- 雪景场景:基础几何体非常粗糙,顶点绘制数据也很粗——但 材质高度图 驱动积雪自然积聚在 凹缝中 ,提供了高频且物理合理的细节
- 角色:材质混合用于添加 高频平铺细节(如纹路、伤痕) ,例如 Cacodemon 的皮肤细节
GPU 三角形剔除与合并(GPU Triangle Culling & Merging)
背景与动机
- Doom Eternal 的屏幕几何量预算约为 Doom 2016 的 3 倍,但必须在相同硬件上维持相同帧率
- 关键手段:GPU 三角形剔除(GPU Triangle Culling) —— 在帧的早期用 Compute Shader 移除不可见三角形,生成仅包含存活几何体的 间接索引缓冲区(Indirect Index Buffer)
- 好处:减轻图形硬件管线压力,避免无用的顶点线程运行——在 GCN 架构 上,无用顶点线程极易引发 级联性能问题(Cascading Stall)
GPU 三角形剔除的实现
剔除类型
Compute Shader 执行三种剔除,方法与 Frostbite 引擎在 "Optimizing the Graphics Pipeline with Compute" 中描述的非常相似:
- 背面剔除(Backface Culling)
- 视锥剔除(Frustum Culling)
- 微三角形剔除(Micro-triangle Culling) —— 剔除投影后小到看不见的三角形
遮挡剔除(Occlusion Culling)
- 使用 保守深度图链(Conservative Depth Map Chain)
- 基于 Umbra 提供的 软件遮挡缓冲区(Software Occlusion Buffer) ,原本用于 CPU 剔除,此处复用于 GPU 剔除
与 CPU 剔除的关系
- GPU 三角形剔除 在标准 CPU 剔除流程之后 额外运行
- 两者叠加后,大多数场景中 约 70% 的已提交几何体被移除
- 典型数据:CPU 剔除 + LOD 选择后约 300 万可见三角形,经 GPU 剔除后仅 约 100 万三角形 进入图形管线
仅靠剔除不够——Draw Call 爆炸问题
问题描述
- Doom Eternal 关卡由 大量实例化模型(Instanced Models) 组成,视野中可达 15,000 个
- 远处模型使用低 LOD,面数很少,导致:
- 顶点着色器 Lane 利用率低(大量线程空闲)
- 命令处理器(Command Processor)开销高
- GPU 占用率(Occupancy)差
- 发出 15,000 个 Draw Call 即使循环再紧凑,CPU 耗时也非常可观
- 如果还需要额外跑一个 Depth Pre-Pass,开销更是翻倍
动态几何合并(Dynamic Geometry Merging)
核心思想
利用已有的 动态生成索引缓冲区 机制,将多个模型自动合并到 一个最优缓冲区 中,用 单个 Draw Call 渲染。
合并后的效果
- 顶点着色器中 接近 100% 的 Lane 都在做有用工作
- 无硬件三角形拒绝(No Hardware Triangle Rejection)
- GPU 占用率显著提升
合并的前提条件
相同管线状态(Same Pipeline State)
- 所有被合并的几何体必须使用 相同的 Pipeline State
- 不同 材质(Material) 可以合并,只要 Pipeline State 一致
- 这与 id 引擎 极少 Shader 的设计哲学完美契合——少量 Shader 而非数千种排列组合
完全无绑定的 GPU 管线(Fully Bindless Pipeline)
所有资源通过 全局索引 访问,无需为每个 Draw Call 单独绑定纹理或缓冲区。具体包含三部分:
| 资源类型 | 实现方式 |
|---|---|
| 顶点数据 | 从 单一大型池(Single Large Pool) 中分配,与几何体流式加载系统一致 |
| 纹理 | 全局可索引;Image Streamer 维护一份 全局纹理描述符列表(Global Texture Descriptors) ,随流式加载动态更新 |
| Uniform Buffer | 从 池 中分配,每种支持合并的 Pipeline Layout 对应一个专用池 |
- Doom Eternal 中实际只有 3 种可索引的 Layout:
- 环境(Environment)
- 角色(Characters)
- 几何缓存(Geometry Caches)
运行时流程
CPU 端
- 场景遍历(Scene Traversal) 时收集可见网格列表
- 将共享同一 Pipeline State 的网格组装为 几何集(Geometry Set) ,每个集最多 256 个网格
- 为每个几何集 预留间接索引缓冲区空间
- 为每个几何集发出 一个 GPU 剔除 Dispatch
GPU 端(剔除 + 合并 Compute Shader)
- 输出 合并后的索引缓冲区(Merged Index Buffer) ,仅包含整个集中的可见三角形
- 每个 32 位输出索引 中 打包了 Vertex ID 和 Instance ID
- 渲染时可从中获取实例数据
- 保证硬件 顶点复用(Vertex Reuse) 的正确性
渲染阶段
- 每个几何集仅需 一个 Draw Call → 一次性渲染最多 256 个网格
- 在 顶点着色器 中:
- 从打包的索引值中提取 Vertex ID 和 Instance ID
- Instance ID 用于解析所有逐实例属性:
- 全局池中 顶点数据的基础偏移
- 逐实例 Uniform Buffer 偏移
- Uniform Buffer 中存储所有 全局纹理索引 和其他着色器常量
发散访问的处理(Divergent Access)
- 合并后,同一个 Wave/Wavefront 中不同线程可能访问 不同纹理和缓冲区 → 发散访问(Divergent Access)
- 解决方式取决于平台:
- 显式标量循环(Explicit Scalar Loop)
- 内置关键字,如 HLSL 的
NonUniformResourceIndex
- 实际影响 微乎其微:
- 大部分发散获取发生在 顶点着色器 中
- 或发生在像素着色器的 非热路径 中
- 合并带来的其他节省远远超过发散带来的额外开销
双代码路径:合并版 vs 标准版
- 着色器同时支持 合并模式 和 标准非合并模式
- 便于 A/B 测试
- 用于处理 间接索引缓冲区溢出 的情况
- 通过 内部着色器预处理器 自动生成两个版本:
- 合并版本 的调整包括:
- 用 缓冲区获取(Buffer Fetch) 替代顶点属性
- 增加实例数据的 间接寻址
- 对发散获取使用 标量化循环
- 合并版本 的调整包括:
GPU 调度与并行
- 剔除与合并 Shader 作为 异步 Compute(Async Compute) 运行
- 在 阴影 Pass 之前 发出,因此与阴影渲染 并行执行
- GPU 同步粒度为 每个几何集,复杂场景中可能实现:
- 一个几何集的剔除 与 另一个几何集的渲染 重叠执行
CPU 端额外收益
- 准备 GPU 剔除与合并缓冲区的 CPU 代码是 高度并行(Embarrassingly Parallel) 的,直接作为 场景遍历(Scene Traversal) 的一部分执行
- 有效 消除了 逐个准备和发出 Draw Call 带来的 大部分 API 开销
- 原本需要两个 Draw Call 密集的 Pass(Depth + Opaque),现在替换为场景遍历中的简单缓冲区设置代码
调试可视化与实际效果
调试模式
- 逐网格着色:为场景中每个网格分配唯一颜色,可观察到大量实例化模型(角色由多个表面组成,战斗中触发伤口后更多)
- 逐几何集着色:为每个 Draw Call 分配唯一颜色
- 整个环境仅需 极少 Draw Call 即可渲染
- 典型场景中所有角色(如前景蜘蛛机器人 + 背景战斗中的小鬼)全部在一个 Draw Call 中完成
性能数据
| 指标 | 数值 |
|---|---|
| GPU 节省 | 最重场景中节省最多 5 毫秒 GPU 时间 |
| 几何处理浪费 | 接近零——几乎所有顶点线程活跃,几乎无三角形被固定功能管线拒绝 |
| CPU 节省 | 显著减少,两个 Draw Call 密集 Pass 替换为简单的缓冲区设置 |
血腥系统(Gore System)
设计目标
- 为玩家提供 令人满足的战斗反馈:敌人受伤时产生 清晰的轮廓变化(Silhouette Changes)
- 通过预建模的 独立伤口网格(Wound Mesh) 实现,在特定时机显露
伤口渲染机制
每个伤口网格附带两张遮罩
| 遮罩类型 | 作用 |
|---|---|
| 裁剪遮罩(Clip Mask) | 丢弃(Discard)基础材质中覆盖伤口的几何体,露出下方的伤口 |
| 血液遮罩(Blood Mask) | 驱动角色着色器中 血液扩散效果(Tightening Blood Effect) 的混合权重 |
复杂度
- 每个角色最多可有 30 种不同伤口
- 战斗中单个角色可同时激活 12 个以上伤口
- 同时在场最多 16 个活跃敌人
- 每个角色由 多个基础材质 组成 → 潜在的 Mesh Part 绘制数量非常大
- 但这与前述的 GPU 三角形剔除与合并 流程 无缝协作,不会成为性能瓶颈
状态缓存优化
问题
- 最复杂角色有 30 种伤口,每帧逐一采样代价太高
解决方案:专用缓冲区(Dedicated Buffer)
- 为每个角色实例分配 单字节(1 Byte) 存储
- 将 当前裁剪权重(Clip Weight) 和 血液权重(Blood Weight) 打包进同一字节
- 仅在伤口被施加时更新对应值
- 额外功能:即使某次攻击未触发网格伤口,也可以增加 血液权重,为玩家提供 即时反馈
- 顶点和片元着色器只需 采样一次该缓冲区,效率极高
水体渲染(Water Rendering)
位移图生成(Displacement Map Generation)
基于 Tessendorf 2001 的海洋模拟
- 频域生成随机波谱:使用半经验模型 Phillips 频谱(Phillips Spectrum) 生成水面波浪的频域数据
- FFT 变换:通过 Compute Shader 执行 快速傅里叶变换(FFT) ,将频域数据转化为 位移向量(Displacement Vectors)
- 输出 256×256 位移图(分辨率可独立于其他水体 Pass 调整)
- 从位移图通过 有限差分法(Finite Difference) 生成 法线图(Normal Map)
水面网格生成——投影网格(Projected Grid)
基于 Johanson 2004 的投影网格概念
核心思路:不直接细分水平面,而是将 屏幕空间网格 沿视锥 投影到水平面 上,再施加位移。
具体管线流程
阶段一:渲染水面深度缓冲
- 将水面平面渲染到一个 与屏幕空间网格分辨率匹配 的深度缓冲( 256×256 )
- 分辨率匹配确保每个深度纹素与网格顶点 一一对应
阶段二:Compute Pass——投影与位移
- 对每个深度纹素:
- 从深度值 重建世界坐标 → 相当于将屏幕空间网格投影到水平面
- 应用 位移图的位移(位移图在世界 XY 平面上简单 Tiling)
- 应用 水体撞击涟漪(Water Hit Ripples) 的额外位移
- 输出 顶点图像(Vertices Image) :每个纹素存储对应网格顶点的 最终世界坐标
时间累积(Temporal Accumulation)
- 顶点图像与 前一帧的顶点图像 进行时间累积
- 目的:由于顶点图像分辨率低,不做累积会导致 屏幕空间网格在水面下"滑动" ,产生明显的 闪烁伪影(Shimmering)
- 启用后摄像机平移时闪烁大幅减少
阶段三:屏幕空间网格光栅化
- 顶点着色器中,每个顶点 查询顶点图像获取世界坐标
- 不在水面上方的网格顶点 → 位置设为 NaN(如
1/0)→ 整个三角形被丢弃 - 片元着色器中 法线使用有限差分计算 而非前面生成的法线图
- 原因:生成的法线图 不包含水体撞击效果
- 该法线图改为作为 扰动法线(Perturbation) 使用,以更高频率 Tiling 叠加 额外凹凸细节
- 输出 水体 G-Buffer( 半水平分辨率 ),包含深度、法线、环境光照
水面边缘的阶梯伪影
- 由于丢弃非水面顶点的三角形,水面边缘呈 阶梯状(Stair-Step)
- 解决方案:美术必须将所有水面边缘 隐藏在不透明几何体后方
- 片元着色器中还执行 保守深度剔除(Conservative Depth Culling) :水面片元深度 > Tile 最大深度时直接 Discard
水面光照
环境光优化
- 逐屏幕空间网格顶点 计算环境光照(而非逐 G-Buffer 像素),在网格光栅化的 顶点着色器 中完成
- 插值后的结果存入水体 G-Buffer → G-Buffer 实际包含 深度 + 法线 + 环境光
漫反射分量
- 不使用常规 漫反射 BRDF
- 改用 Henyey-Greenstein 相位函数 模拟水面 颗粒散射(Water Particulate Scattering)
- 该分量根据 水深缩放:
- 深水 → 颜色更深/更红
- 浅水 → 更清澈
自发光分量(Emissive)
- 美术可设置 恒定自发光颜色
- 同样按水深缩放:深水更亮,浅水更暗
输出
- 最终水面光照输出到 半水平分辨率缓冲区
- 包含光照结果 + 水面 Alpha(用于最终合成)
屏幕空间反射(SSR)
- 在最终光照 Pass 之前,单独计算 水面 SSR 并输出到独立渲染目标
- 对 SSR 单独做 时间累积 → 提高采样率、减少 萤火虫噪点(Fireflies)
- 同样使用 半水平分辨率
合成管线:Before/After Water 分离
为何需要分离
- 水面折射需要 扭曲水面后方像素的纹理坐标
- 必须区分 水面前方 和 水面后方 的像素
实现流程
- 所有不透明表面渲染到主颜色缓冲后、透明表面渲染前,执行 分离 Pass
- 比较主深度缓冲与水体 G-Buffer 深度,将主颜色缓冲拆为:
- Before Water 缓冲(水面前方像素)
- After Water 缓冲(水面后方像素)
- 额外维护 Before Water Alpha 缓冲:追踪混合到 Before Water 中的透明表面 Alpha
- 原因:水面前方的透明表面可能直接位于水面正前方,合成时需要正确混合
透明表面渲染的修改
- 所有透明着色器必须修改:检查最终片元深度与水面深度,决定混合到 Before Water 还是 After Water
最终合成(Final Composite)
- 从后到前 依次混合:After Water → Water Surface → Before Water
- 读取 After Water 时 扭曲纹理坐标 模拟折射(基于 Sousa 2008 的方法)
- 扭曲量仅基于 水面法线(非物理精确)
水体撞击涟漪(Water Hit Ripples)
高度场模拟
- 使用以摄像机为中心的 高度场纹理(Height Field Texture) ,随摄像机移动
- 模拟算法基于 Müller-Fischer 2008
- 每帧从前一帧高度场 时间步进 到当前帧
- 前一帧高度场通过 重投影纹理坐标(Reprojected Tex Coords) 获取,补偿摄像机移动
网格对齐技巧
- 高度场网格位置 对齐到网格单元大小的整数倍
- 确保重投影纹理坐标 恰好落在纹素中心,避免引入额外的线性插值误差
水体焦散(Water Caustics)
生成可平铺焦散纹理
- 光栅化一个 网格(Grid Mesh) ,每个顶点与位移图纹素对齐
- 对每个顶点:
- 从位移图采样得到水面位置
- 计算 折射方向(Refraction Direction)
- 沿折射方向追踪到 恒定距离
- 效果等同于将整个网格"射向"水面位移图并折射到固定深度,扭曲后的网格密度变化产生焦散图案
使用方式
- 生成的焦散纹理 在下一帧使用
- 投影到所有 水下或紧贴水面上方 的场景几何体上
- 集成到 主光照代码 中 → 投影到所有表面
- 集成到 光散射 Pass(Light Scattering Pass) 中 → 实现水下 上帝光线(God Rays)
流动水体(Flowing Water)
实现方式
- 流动水面(瀑布、溪流等)直接光栅化到水体 G-Buffer
- 法线通过 法线图 + 美术制作的流向图(Flow Map) 扰动
- 不施加任何位移 → 已知的改进空间
- 仅依靠法线扰动 + 流向图即可获得视觉上可信的流水效果
水体渲染管线总结
位移图生成(FFT)
↓
水面深度缓冲(256×256)
↓
Compute Pass:投影 + 位移 → 顶点图像(+ 时间累积)
↓
屏幕空间网格光栅化 → 水体 G-Buffer(半水平分辨率)
↓
水面 SSR(+ 时间累积)
↓
水面光照 → 水面颜色 + Alpha
↓
Before/After Water 分离
↓
透明表面渲染(分别混合到两个缓冲)
↓
最终合成(After Water 折射扭曲 → Water Surface → Before Water)
总结
- 演讲涵盖了 Doom Eternal 在 idTech 7 引擎下的多项核心渲染技术:光源分箱、几何贴花、几何缓存、GPU 三角形剔除与合并、血腥系统、水体渲染等
- 特别感谢 id Software 同事以及 AMD 和 NVIDIA 的合作伙伴