虚幻引擎是如何渲染一帧的
虚幻引擎、HDR 渲染管线、WebGL1、Cesium
目录
目的:了解 UE 中比较重要的渲染 passes,作为一个 HDR 渲染管线的参考,并在 Web 端复现。
场景
用 Cesium For Unreal 插件新建一个场景,包含:
- 一个 CesiumSkyLight,其内包含:
- 一个 Directional Light 组件
- 一个 SkyLight 组件
- 一个 SkyAtmosphere 组件
- CesiumTerrain
- 2 个 UE 自带的人物模型,其中一个模型的 Shading Model 为 Clear Coat,另一个为 Default Lit
- 一个半透明的球体
- 一系列金属度和粗糙度不同的球体
- 一个 Default Lit 的立方体
在UE 编辑器内的渲染效果:
总览
用 Render Doc 插件捕获一帧:
我们主要看和渲染相关的红框部分,分别有:- SkyAtmosphereLUTs:计算大气的 look up tables
- CaptureConvolveSkyEnvMap:实时捕获天空的环境光贴图(SkyLight 组件),并计算对应的低频漫反射部分,拟合为球谐函数系数
- BasePass:渲染所有不透明物体的信息到 GBuffer 中
- ShadowDepths:对场景内每盏勾选了 cast shadow 的灯渲染所有不透明物体的深度到 Shadow Map Atlas 中
- LightCompositionTasks_PreLighting:计算 SSAO,用于下一个 Lights 阶段
- Lights:使用 GBuffer、Shadow Map Atlas、SSAO 计算直接光照,此时阴影内的像素值应该为(0,0,0)
- ReflectionIndirect:在直接光照的基础上叠加间接光照,包括 SSR、平面反射和天空光 IBL,此时阴影内的像素应该不为(0,0,0)
- SkyAtmosphere:在未被物体覆盖的像素上渲染大气,和在被物体覆盖的像素上应用空气透视
- Translucency:渲染半透明物体到单独的纹理中
- PostProcessing:最后的后处理部分,包括 TSR 抗锯齿、自动曝光、bloom 和 tone mapping
SkyAtmosphereLUTs
首先是计算和大气渲染相关的 look up tables,用于后续的大气和空气透视渲染
这些 LUTs 大概是这样的:
其中:
- Transmittance、Multi-Scattering 和 Sky View 用于后续的大气渲染
- Distant Sky Light 是一个 1x1 分辨率的纹理,保存了大气在 6km 处的平均颜色,用于体积云的渲染
- Real Time Capture Sky View 用于天空光 Cube Map 的快速生成
- Camera Volume 是一个 3D 纹理(对不支持 3D 纹理的 API 则渲染到 2D 纹理上),用于不透明物体的空气透视
UE 的大气渲染总结到一篇 paper 中 [1] ,具体的实现还得结合对应的 UE 源码查看。
CaptureConvolveSkyEnvMap
这一 pass 会捕获天空(大气和体积云)作为场景物体的环境光贴图。因为场景内短时间的变化,天空的视觉效果不会发生很大的变化,所以为了分摊渲染的开销,会使用时间切片(time slice)的方法来渲染这个环境光贴图。
默认情况下是开启时间分片的,可以通过命令 r.SkyLight.RealTimeReflectionCapture.TimeSlice 0
关闭。如果使用时间切片,则每 12 帧才会完成一次完整的计算,1 帧只完成其中的一个步骤,否则会在一帧内完成所有的计算。
关闭时间切片时,调用的图形 API 如下:
这 12 个步骤分别是:
- 渲染大气到
Captured
SkyRenderTarget
,对应图中的 6 个 Draw(3) - 渲染体积云(如果场景内有的话)到
Captured
SkyRenderTarget
,对应图中的 6 个 Draw(3) - 计算
Captured
SkyRenderTarget
的所有 mipmaps,对应图中的所以 MipGen。因为默认情况下,这个立方体贴图的分辨率是 128x128,所以需要生成除了 0 级之外的其它 7 个 level - 对
Captured
SkyRenderTarget
的 mip level 0 的 face 0 和 1 进行卷积,渲染到Convolved
SkyRenderTarget
对应的 mip level 的 faces 中,对应图中的第 1 个 Convolve - 同上,但是是对 mip level 0 的 face 2 和 3 进行卷积,对应图中的第 1 个 Convolve
- 同上,但是是对 mip level 0 的 face 4 和 5 进行卷积,对应图中的第 1 个 Convolve
- 同上,但是是对 mip level 1 的所有 faces 进行卷积,对应图中的第 2 个 Convolve
- 同上,但是是对 mip level 2 的所有 faces 进行卷积,对应图中的第 3 个 Convolve
- 同上,但是是对 mip level 3 的所有 faces 进行卷积,对应图中的第 4 个 Convolve
- 同上,但是是对 mip level 4 和 5 的所有 faces 进行卷积,对应图中的第 5 个和第 6 个 Convolve
- 同上,但是是对 mip level 6 及之后 levels 的所有 faces 进行卷积,对应图中的第 7 个和第 8 个 Convolve
- 通过卷积得到的
Convolved
SkyRenderTarget
,计算对应的球谐函数系数,它是 7 个float4
的数据,对应图中的 ComputeSkyEnvMapDiffuseIrradianceCS
其中所谓的卷积即过滤,为的是避免使用高频的立方体贴图来生成 SH 系数,因为用这些 SH 系数插值出来的漫反射立方体贴图可能会存在 "ringing" 问题 [2] 。
经过这一 pass 后,我们会得到 2 个有用的数据,用于 IBL 镜面反射的 Convolved
SkyRenderTarget
和由 7 个 float4
组成的用于 IBL 漫反射的 SH 系数。对应的 UE 代码位于 ReflectionEnvironmentRealTimeCapture.cpp 文件
BasePass
这一 pass 会渲染场景内所以不透明物体的 GBuffer 和深度值。
渲染过程如下:
RenderTargets 的信息如下:
- RT0(SceneColor,
R16G16B16A16_FLOAT
):rgb 通道保存emission color * preExposure
- RT1(SelectionOutlineColor,
R10G10B10A2_UNORM
):rgb 通道保存世界坐标的法线,a 通道保存了 GBuffer.PerObjectGBufferData - RT2(GBufferB,
R8G8B8A8_UNORM
):rgb 通道分别保存 Metallic、Specular 和 Roughness,a 通道保存了GBuffer.ShadingModelID | GBuffer.SelectiveOutputMask
- RT3(GBufferC,
R8G8B8A8_SRGB
):rgb 通道保存了 base color,a 通道保存了GBuffer.IndirectIrradiance * GBuffer.GBufferAO
- RT4(GBufferD,
R8G8B8A8_UNORM
):保存GBuffer.CustomData
- RT5(GBufferE,
R8G8B8A8_UNORM
):保存GBuffer.PrecomputedShadowFactor
- RT6(GBufferF):rgb 通道保存
GBuffer.WorldTangent
,a 通道保存GBuffer.Anisotropy
- Depth(D32S8):depth 值和 stencil 值
ShadowDepths
这一 pass 会渲染 shadow map。
对于方向光,会计算 3 个距离的 cascade 到一个 shadow map atlas 中,例如当场景内有一个 Directional Light 充当太阳光时,渲染过程大概是这样的:
LightCompositionTasks_PreLighting
这一 pass 会根据法线计算屏幕空间的 AO。
- AmbientOcclusionSetup 618x319:首先是计算半宽高分辨率的法线
- AmbientOcclusionPS 618x319:然后是计算半宽高分辨率的 AO
- AmbientOcclusionPS 1236x638:最后把半宽高的 AO 上采样到全分辨率(这里值全为 1,因为距离太远,所以不应用 SSAO?)
Lights
这一 pass 会计算直接光照。
用每盏灯的信息、GBuffer、SSAO、Shadow Map Atlas
在着色器内根据 Shading Model 使用不同的方法计算每个像素的直接光照,对应的 UE 着色器代码是 DeferredLightPixelShaders.usf。对于 Default Lit,基本计算公式就是:
$$ (L \cdot \cos{(\theta)} \cdot \text{BRDF}) * \text{shadow} $$
和 glTF 不同,在 BRDF 的计算中,除了金属度和粗糙度之外,UE 的 Default Lit 还使用了一个 Specular 值。
因为乘上了阴影,所以此时阴影内的像素是全黑的。直接光照的渲染结果:
ReflectionIndirect
这一 pass 分为 2 部分,首先计算 SSR(屏幕空间反射,或称为屏幕空间光线追踪),然后再应用 (SSR + 平面反射 + 天空光 Convolved Cube Map) 作为镜面反射和 SH 系数重建的 irradiance cube map 作为漫反射。
SSR 纹理的生成使用的是上一帧的渲染结果,生成的 SSR 纹理:
然后是对 Lights pass 的直接光照渲染结果叠加上 SSR 和 CaptureConvolveSkyEnvMap pass 生成的天空光环境光贴图计算 IBL:
和直接光照相比,此时阴影内的像素不再是黑色的了,对应的着色器代码位于 ReflectionEnvironmentPixelShader.usf 内。
SkyAtmosphere
这一 pass 会将大气渲染到没有物体覆盖的像素(即对应的深度值为最远处的深度值,例如 1.0),且对有物体覆盖的像素(即对应的深度值不为最远处的值)应用空气透视。
这一 pass 会使用到 SkyAtmosphereLUTs pass 生成的 LUTs,最后结果为:
Translucency
这一 pass 会渲染半透明物体到单独的纹理中。
结果:
PostProcessing
至此,已经得到了场景的 HDR 的渲染结果,现在需要进行最后的后处理部分,对应的文件是 PostProcessing.cpp。
TemporalSuperResolution
这一 pass 会对叠加半透明物体的纹理,并应用时间上地超分辨率抗锯齿(TSR),最终得到完整的渲染结果。
Downsample
这一 pass 会对之前的渲染结果进行降采样,宽高各降低为 $\frac{1}{2}$,作为后处理的输入。
SceneDownsample
这一 pass 会对 $\frac{1}{2}$ 宽高的渲染结果进行降采样,结果为 $\frac{1}{4}$、$\frac{1}{8}$、$\frac{1}{16}$、$\frac{1}{32}$ 和 $\frac{1}{64}$ 宽高的渲染结果。
BasicEyeAdaptation
UE 的自动曝光方式有“Histogram”和“Basic”这两种方式,因为“Histogram”的方式需要 compute shader,为了能在用 WebGL 复现,我们选择的是“Basic”的自动曝光方式。
“Basic”方式的主要思路是对降采样的最后一个纹理(即 $\frac{1}{64}$ 宽高)进行遍历,采样每个纹素值,然后根据这些纹素值计算它们的平均亮度和曝光值,并把计算出来的信息保存到一个 1x1 纹理的 4 个分量中。
Bloom
这一 pass 会计算泛光纹理。泛光纹理的计算使用的是 6 层的高斯过滤。
最后计算出来的泛光纹理:
CombineLUTS
这一 pass 主要是计算一个用于 tone map 的 32x32x32 LUT。这个 LUT 会根据后处理的设置来生成,参数包含 film tone mapping 和用户自定义的调色。这个 LUT 大概张这样:
Tonemap
最后一 pass 是 tone mapping。输入的纹理是 BasicEyeAdaptation pass 生成的 1x1 纹理、TSR pass 生成的渲染结果、Bloom pass 生成的泛光纹理和 CombineLUTS pass 生成的 LUT。最终的输出就是场景的渲染内容。
最后还有一些和 UE 编辑器相关的渲染,例如选中模型时的黄色轮廓和模型的变换轴(gizmo)。
Web 端的复现
下面是在 Web 端用 Cesium.js/WebGL1 对这整套渲染管线的复现效果:
效果和 UE 相差不大,当然,还有一些是未复现的,包括:
- TSR 抗锯齿
- SSR
- SSAO
- 阴影
- 体积云