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" 中描述的非常相似:

  1. 背面剔除(Backface Culling)
  2. 视锥剔除(Frustum Culling)
  3. 微三角形剔除(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
    1. 环境(Environment)
    2. 角色(Characters)
    3. 几何缓存(Geometry Caches)

运行时流程

CPU 端

  1. 场景遍历(Scene Traversal) 时收集可见网格列表
  2. 将共享同一 Pipeline State 的网格组装为 几何集(Geometry Set) ,每个集最多 256 个网格
  3. 为每个几何集 预留间接索引缓冲区空间
  4. 为每个几何集发出 一个 GPU 剔除 Dispatch

GPU 端(剔除 + 合并 Compute Shader)

  • 输出 合并后的索引缓冲区(Merged Index Buffer) ,仅包含整个集中的可见三角形
  • 每个 32 位输出索引打包了 Vertex ID 和 Instance ID
    • 渲染时可从中获取实例数据
    • 保证硬件 顶点复用(Vertex Reuse) 的正确性

渲染阶段

  • 每个几何集仅需 一个 Draw Call → 一次性渲染最多 256 个网格
  • 顶点着色器 中:
    • 从打包的索引值中提取 Vertex IDInstance 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——投影与位移

  • 对每个深度纹素:
    1. 从深度值 重建世界坐标 → 相当于将屏幕空间网格投影到水平面
    2. 应用 位移图的位移(位移图在世界 XY 平面上简单 Tiling)
    3. 应用 水体撞击涟漪(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 分离

为何需要分离

  • 水面折射需要 扭曲水面后方像素的纹理坐标
  • 必须区分 水面前方水面后方 的像素

实现流程

  1. 所有不透明表面渲染到主颜色缓冲后、透明表面渲染前,执行 分离 Pass
  2. 比较主深度缓冲与水体 G-Buffer 深度,将主颜色缓冲拆为:
    • Before Water 缓冲(水面前方像素)
    • After Water 缓冲(水面后方像素)
  3. 额外维护 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) ,每个顶点与位移图纹素对齐
  • 对每个顶点:
    1. 从位移图采样得到水面位置
    2. 计算 折射方向(Refraction Direction)
    3. 沿折射方向追踪到 恒定距离
  • 效果等同于将整个网格"射向"水面位移图并折射到固定深度,扭曲后的网格密度变化产生焦散图案

使用方式

  • 生成的焦散纹理 在下一帧使用
  • 投影到所有 水下或紧贴水面上方 的场景几何体上
  • 集成到 主光照代码 中 → 投影到所有表面
  • 集成到 光散射 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 同事以及 AMDNVIDIA 的合作伙伴