Unreal Engine 4 渲染 (4) 延迟着色管道 The Deferred Shading Pipeline

本文深入探讨了Unreal Engine 4中的延迟着色渲染管道,包括延迟着色的基本通道、顶点工厂的运作、GBuffer的创建以及光照和阴影的处理。详细阐述了顶点工厂如何更改输入和输出数据,以及如何在像素着色器中处理材质图和光照。此外,还介绍了如何计算动态光照,包括阴影和无阴影光照的计算。通过对延迟光照像素着色器的分析,揭示了光照对像素影响的计算过程。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

https://medium.com/@lordned/unreal-engine-4-rendering-part-4-the-deferred-shading-pipeline-389fc0175789

翻译:yarswang 转载请保留

 

延迟着色基本通道

在第3章,我们完成了对C++方面的研究,并最终准备好了解GPU上的一切是如何工作的。 我们将深入研究顶点工厂如何控制公共基本顶点着色器着色器代码的输入,以及如何处理曲面细分(使用其附加的Hull和Domain阶段),以及触及材质图表如何结束 在你的HLSL代码中。

 

在我们理解了这些部分是如何组合在一起之后,我们将逐步完成延迟通道并查看它经历的一些不同步骤。 这将让我们知道表面着色器在哪个部分运行,以及材料图中的修改内容实际上最终会做什么。

 

顶点工厂再览

回到第2章,我们简要讨论了顶点工厂如何去更改提供给顶点着色器的数据。Unreal最终明智地决定用提升学习复杂度的代价以减少代码重复。 我们将在我们的示例中使用LocalVertexFactory.ushBasePassVertexCommon.ush,并将GpuSkinVertexFactory.ush作为要比较的东西,因为它们都使用相同的顶点着色器。

 

更改输入数据

不同类型的网格最终将需要不同的数据来完成它们的工作,即:GPU蒙皮顶点需要比简单静态网格更多的数据。 Unreal使用FVertexFactory处理CPU端的这些差异,但在GPU方面它有点棘手。

 

因为所有顶点工厂共享相同的顶点着色器(至少对于基础通道),所以它们使用通常命名的输入结构FVertexFactoryInput

 

因为Unreal使用相同的顶点着色器,但每个顶点工厂包含不同的代码,Unreal重新定义了每个顶点工厂中的FVertexFactoryInput结构。此结构被唯一定义在GpuSkinVertexFactory.ushLandscapeVertexFactory.ushLocalVertexFactory.ush和其他一些文件中。显然包含所有这些文件是行不通的 - 而BasePassVertexCommon.ush引用了/Engine/Generated/VertexFactory.ush。 编译着色器时,这将设置为正确的顶点工厂,允许引擎知道要使用哪个FVertexFactoryInput实现。我们在第2章简要地谈到了使用宏来声明顶点工厂,你必须提供着色器文件 - 这就是原因。

 

现在我们的基本通道顶点着色器的数据输入与我们上传的顶点数据的类型相匹配。下一个问题是不同的顶点工厂将需要在顶点着色器和像素着色器之间插入不同的数据。同样,BasePassVertexShader.usf调用泛型函数 - GetVertexFactoryIntermediatesVertexFactoryGetWorldPositionGetMaterialVertexParameters。如果我们在文件中执行另一个查找,我们会发现每个 *VertexFactory.ush 都按需而将这些函数独特定义。

 

每个顶点工厂重新实现这个方法

 

更改输出数据

现在我们需要了解如何从Vertex Shader到Pixel Shader获取数据。不出所料,BasePassVertexShader.usf的输出是另一个通常命名的struct(FBasePassVSOutput),它的实现取决于顶点工厂。虽然这里有一点障碍 - 如果你启用了曲面细分,那么顶点着色器和像素着色器之间有两个阶段(Hull和Domain阶段),这些阶段需要的数据不同于只是顶点着色器到像素着色器的数据。

 

Unreal的下一个技巧。他们使用#define来改变FBasePassVSOutput的含义,它可以定义为简单的FBasePassVSToPS结构,也可以为曲面细分定义为FBasePassVSToDS(此代码可以在BasePassVertexCommon.ush中找到)。这两种结构具有几乎相同的内容,除了Domain着色器版本添加了一些额外的变量。那么,我们需要的那些独特的每个顶点工厂中插值呢? Unreal通过创建FVertexFactoryInterpolantsVSToPSFBasePassInterpolantsVSToPS作为FBasePassVSOutput的成员来解决这个问题。惊喜! FVertexFactoryInterpolantsVSToPS在每个 *VertexFactory.ush 文件中定义,这意味着我们仍然在阶段之间传递正确的数据,即使我们停止在中间添加Hull/Domain阶段。 FBasePassInterpolantsVSToPS未重新定义,因为存储在此结构中的内容不依赖于特定顶点工厂特有的任何内容,包含VertexFog值,AmbientLightingVector等内容。

 

Unreal的重定义技术抽象出基本传递顶点着色器中的大部分差异,允许公共代码不管曲面细分或特殊顶点工厂都能被使用。

 

基本通道顶点渲染

现在我们知道Unreal如何处理着色器阶段和曲面细分支持中的差异,我们将看看延迟渲染管道中每个着色器实际上做了什么。

 

BasePassVertexShader.usf总体来说非常简单。在大多数情况下,顶点着色器只是计算和分配BasePassInterpolants和VertexFactoryInterpolants,虽然如何计算这些值会变得有点复杂 - 有很多特殊情况,他们选择只在某些预处理器定义下声明某些插值器和然后只分配匹配定义下的那些。

 

例如,在顶点着色器的底部附近,我们可以看到定义#if WRITES_VELOCITY_TO_GBUFFER,它通过计算其位置最后一帧与该帧之间的差异来计算每个顶点的速度。一旦计算出来,它就会将它存储在BasePassInterpolants变量中,但是如果你查看那里,他们就会在匹配的#if WRITES_VELOCITY_TO_GBUFFER中包装该变量的声明。

 

这意味着只有向GBuffer写入速度的着色器变体才会计算它 - 这有助于减少阶段之间传递的数据量,这意味着带宽减少,从而导致更快的着色器。

 

您不必接触的Boilerplate代码!

 

基本通道像素着色器

这是事情开始变得相当复杂的地方,并且可能是每个人都被吓跑的地方。 深呼吸,假设预处理器检查中的大部分内容都不存在,我们将一起解决这个问题!

 

材质图到HLSL

当我们在Unreal中创建一个材质图时,Unreal会将您的节点网络转换为HLSL代码。 此代码由编译器插入到HLSL着色器中。 如果我们看一下MaterialTemplate.ush就会发现它包含许多没有实体的结构(比如FPixelMaterialInputs) - 相反它们只有一个 %s。 Unreal将其用作字符串格式,并将其替换为特定于材质图的代码。

 

此文本替换不仅限于结构,MaterialTemplate.ush还包括几个没有实现的函数。 例如,half GetMaterialCustomData0half3 GetMaterialBaseColorhalf3 GetMaterialNormal都是根据您的素材图填写其内容的不同函数。 这允许你从像素着色器调用这些函数,并知道它将执行你在材质图中创建的计算,并将返回该像素的结果值。

 

一些函数的内容由C++ 填充,其它的则具有实际定义

 

“原始”变量

遍历所有代码,您将找到对名为“Primitive”的变量的引用 - 在着色器文件中搜索它不会返回任何声明! 这是因为它实际上是通过一些宏魔法在C++ 端声明的。 在GPU上绘制每个Primitive变量之前,此宏会声明一个由渲染器设置的结构。

 

它支持的完整变量列表可以在PrimitiveUniformShaderParameters.h中顶部的宏找到。它默认包括LocalToWorld, WorldToLocal, ObjectWorldPositionAndRadius, LightingChannelMask等。

 

创建GBuffer

延迟着色使用“GBuffer”(几何缓冲区)的概念,它是一系列渲染目标,用于存储关于几何的不同信息,例如世界法线,基色,粗糙度等。当照明计算确定最终的阴影时,Unreal会采样这些缓冲区。在它生效之前,虚幻通过几个步骤来创建和填充它。

 

GBuffer的确切内容可能有所不同,根据您的项目设置,可以改变通道数量及其用途。一个常见的案例是5纹理GBuffer,从A到E:GBufferA.rgb = World Normal,使用PerObjectGBufferData填充alpha通道。 GBufferB.rgba = Metallic, Specular, Roughness, ShadingModelIDGBufferC.rgbBaseColor,使用GBufferAO填充alpha通道。 GBufferD专用于自定义数据,GBufferE用于预先计算的阴影因子。

 

BasePassPixelShader.usf中,FPixelShaderInOut_MainPS函数充当像素着色器的入口点。由于有许多预处理器定义,此函数看起来相当复杂,不过大多数都是用样板代码填充的。 Unreal使用几种不同的方法来计算GBuffer所需的数据,具体取决于您启用的照明模型和功能。除非您需要更改某些样板代码,否则首个有意义的功能在函数的中间靠下的地方,着色器用来获取BaseColor, Metallic, Specular, MaterialAORoughness的值。它通过调用MaterialTemplate.ush中声明的函数来完成此操作,它们的实现由你的材质图定义。

 

着色器缓存材质图调用的结果,以避免多次执行其函数。

 

现在我们已经采样了一些数据通道,Unreal将针对某些着色模型修改其中一些数据通道。例如,如果您使用的是使用次表面散射(次表面,次表面轮廓,预整合蒙皮,双面叶子或布料)的着色模型,那么Unreal将根据对GetMaterialSubsurfaceData的调用来计算次表面颜色。如果照明模型不是其中之一,则使用默认值零。 “次表面颜色”值现在是进一步计算的一部分,但除非您使用的是着色模型,否则它将仅为零!

 

计算次表面颜色后,如果在项目中启用了DBuffer,Unreal则允许DBuffer Decals修改GBuffer的结果。在做了一些数学运算后,Unreal将DBufferData应用于BaseColor, Metallic, Specular, Roughness, NormalSubsurface Color通道。

 

在允许DBuffer Decals修改数据后, Unreal计算不透明度(使用材质图中的结果)并进行一些体积光照贴图计算。最后,它创建了FGBufferData结构,并将所有这些数据打包到其中,每个FGBufferData实例代表一个像素。

 

设置GBuffer着色模型

Unreal列表中的下一件事是让每个着色模型按照它认为合适的方式修改GBuffer。为了实现这一点,Unreal在ShadingModelMaterials.ush中有一个名为SetGBufferForShadingModel的函数。此函数采用我们的Opacity, BaseColor, Metallic, Specular, RoughnessSubsurface数据,并允许每个着色模型将数据分配给它想要的GBuffer结构。

 

大多数着色模型只是简单地分配输入数据而不进行修改,但某些着色模型(例如与子表面相关的模型)将使用自定义数据通道将附加数据编码到GBuffer中。此函数的另一个重要功能是将ShadingModelID写入GBuffer。这是每像素存储的整数值,允许延迟传递查找每个像素稍后应使用的着色模型。

 

这里需要注意的是,如果要使用GBuffer的CustomData通道,则需要修改BasePassCommon.ush,它具有WRITES_CUSTOMDATA_TO_GBUFFER的预处理器定义。如果您尝试使用GBuffer的CustomData部分而不确保在此处添加着色模型,它将被丢弃,您将不会获得任何值!

 

单个模型使用三种不同的着色模型 - 头发,眼睛和皮肤。

 

使用数据

既然我们已经让每个照明模型选择他们将数据写入FGBufferData结构的方式,那么BasePassPixelShader将会做更多的样板代码和内部处理 - 计算每个像素的速度,进行次表面颜色变换,重写ForceFullyRough的粗糙度等。

 

在这个样板代码之后,Unreal将获得预先计算的间接光照和天空光照数据(GetPrecomputedIndirectLightingAndSkyLight)并将其添加到GBuffer的DiffuseColor中。这里有相当一些与半透明前向着色,顶点雾化和调试相关的代码,我们最终来到了FGBufferData结构的末尾。 Unreal调用EncodeGBuffer(DeferredShadingCommon.ush),它接收FGBufferData结构并将其写入各种GBuffer纹理A-E。

 

这包含了大部分的基本通道像素着色器的结尾部分。你会注意到此方法中没有提及光照或阴影。这是因为在延迟渲染器中,这些计算推迟到了!接下来我们来看看。

 

综述

基本通道像素着色器负责通过调用材质图生成的函数对各种PBR数据通道进行采样。此数据被打包到FGBufferData中,该数据被传递给基于各种着色模型修改数据的各种函数中。着色模型确定写入纹理的ShadingModelID,以选择稍后使用的着色模型。最后,FGBufferData中的数据被编码为多个渲染目标以供稍后使用。

 

延迟光照像素着色器

接下来我们将看看DeferredLightPixelShaders.usf,因为这是计算光对像素的影响的地方。为此,Unreal使用一个简单的顶点着色器来绘制适当的几何体来匹配每个光的可能影响范围,即:用于点光源的球体和用于聚光灯的锥体。这将在那些需要像素着色器上运行的像素上创建一个掩模,这使得填充较少像素的光照消耗更少。

 

阴影和无阴影光照

虚幻在多个阶段绘制光照。首先绘制不投射阴影光,然后绘制间接光照(通过光照投射体)。最后Unreal绘制所有投射阴影光。 Unreal使用类似的像素着色器进行投射阴影和非投射阴影光 - 它们之间的差异来自投投射阴影光的额外预处理步骤。对于每个灯光,Unreal会计算ScreenShadowMaskTexture,它是场景中阴影像素的屏幕空间表示。

 

ScreenShadowMaskTexture用于一个包含一些球体的简单场景

                           

为此,Unreal会渲染几何图形,这些几何图形与场景中每个对象的边界框以及场景中对象的几何表示体相匹配。 它不会重新渲染场景中的物体,而是对GBuffer进行采样,结合给定像素的深度,以查看它是否会出现投射光影。 听起来复杂吗? 别担心,确实如此。 好消息是,我们唯一需要的是每个阴影光计算处于阴影中的表面的屏幕空间表示体,这些数据后续会用到!

 

基本通道像素着色器

现在我们知道阴影光照创建了一个屏幕空间阴影纹理,我们可以回过头来看看基本通道像素着色器的工作原理。提醒一下,这是针对场景中的每个光照运行的,因此对于具有多个光照影响的任何对象的每个像素将计算多次。像素着色器可以非常简单,我们会对这个像素着色器调用的函数更感兴趣。

 

void RadialPixelMain( float4 InScreenPosition, float4 SVPos, out float4 OutColor)
{
// Intermediate variables have been removed for brevity
FScreenSpaceData ScreenSpaceData = GetScreenSpaceData(ScreenUV);
FDeferredLightData LightData = SetupLightDataForStandardDeferred();

OutColor = GetDynamicLighting(WorldPosition, CameraVector, ScreenSpaceData.GBuffer, ScreenSpaceData.AmbientOcclusion, ScreenSpaceData.GBuffer.ShadingModelID, LightData, GetPerPixelLightAttenuation(ScreenUV), Dither, Random);

OutColor *= ComputeLightProfileMultiplier(WorldPosition, DeferredLightUniforms_LightPosition,     DeferredLightUniforms_NormalizedLightDirection);
}

只有几个功能,所以直接跳过每个的具体实现。GetScreenSpaceData从给定像素的GBuffer中检索信息。 SetupLightDataForStandardDeferred计算诸如光方向,光色,衰减等信息。最后,它调用GetDynamicLighting并传入我们到目前为止计算的所有数据 - 像素在哪里,GBuffer数据是什么,什么是Shading Model ID 使用,和我们的光信息。

 

获得动态照明

GetDynamicLighting函数(位于DeferredLightingCommon.ush)很长,看起来很复杂,但复杂因素很多是因为各种光照的不同设置。此函数计算初始值为1.0的SurfaceShadowSubsurfaceShadow变量 - 如果存在阴影,则该值变为小于1。这一点非常重要,因为我们稍后会将值与它相乘,所以现在只需知道更高的值表示更浅的阴影。

 

如果启用了阴影,则GetShadowTerms会被调用。这使用之前的光衰减缓冲(称为ScreenShadowMaskTexture)来确定给定像素的阴影项。阴影数据可以来自大量不同的地方,(虚幻存储光功能+ z通道中的每个对象阴影,w中的每个对象次表面散射,x中的整个场景平行光阴影和y中整个场景平行光的表面散射,来自相应的GBuffer通道的静态阴影),GetShadowTerms将此信息写入我们之前的SurfaceShadowSubsurfaceShadow变量。

 

现在我们已经确定了表面和次表面的阴影因子,我们计算了光衰减。衰减实际上是基于与光的距离的能量衰减,并且可以被修改以产生不同的效果,即:卡通渲染经常从计算中去除衰减,使得到光源的距离无关紧要。Unreal根据距离,光半径和衰减以及阴影项分别计算SurfaceAttenuationSubsurfaceAttenuation。阴影与衰减绑定,这意味着我们未来的计算仅考虑衰减强度。

 

最后,我们计算了这个像素的表面着色。表面着色计算使用到了GBuffer, Surface Roughness, Area Light Specular, Light Direction, View Direction和Normal。Roughness由我们的GBuffer数据决定。Area Light Specular使用基于物理的渲染(基于我们的光数据和Roughness)来计算新的能量值,并可以修改Roughness和光矢量。

 

标准着色模型在其计算中使用各种数据

 

Surface Shading最终让我们有机会修改每个表面如何响应这些数据。这个函数位于ShadingModels.ush中,它只是一个很大的switch语句,用来查看我们之前写入GBuffer中的ShadingModel ID!许多照明模型共享标准着色功能,但一些更不寻常的着色模型使用自己的自定义实现。表面着色不考虑衰减,因此它仅涉及计算没有阴影的表面颜色。

 

在Light Accumulator运行之前,不会考虑衰减(距离+阴影)。 Light Accumulator将表面照明和衰减考虑在内,并在将它们乘以光衰减值后正确地将表面和次表面照明相加。

 

最后,动态光照函数返回Light Accumulator累积的总光照。实际上,这只是表面+次表面照明,但代码因次表面属性和调试选项而变得复杂。

 

ComputeLightProfileMultiplier

最后,DeferredLightPixelShader所做的最后一件事是将GetDynamicLighting计算的颜色乘以ComputeLightProfileMultiplier中的值。此函数允许使用1D IES光轮廓纹理。如果没有将IES光照配置文件用于该光照,则不会更改结果值。

 

累积光

因为BasePassPixelShaders是针对影响对象的每个光照执行的,所以Unreal会累积此灯光并将其存储在缓冲区中。在ResolveSceneColor步骤中稍后几步之前,此缓冲区甚至不会被绘制到屏幕上。这里之前计算了几个额外的东西,例如半透明物体(使用传统的前向渲染技术绘制),屏幕空间temporal anti aliasing和屏幕空间反射。

 

综述

对于每个光照,阴影数据在屏幕空间中计算,并结合静态阴影、次表面阴影和方向阴影,然后绘制每个光的近似几何图形,并绘制该光对每个像素的影响。表面着色基于GBuffer数据和着色模型计算,然后乘以光衰减。光衰减是光设置(距离,衰减等)和阴影采样的组合。每个表面阴影的输出累积在一起以产生最终的光值

 

下一章

我们已经介绍了有关渲染系统的大量背景信息以及不同部分如何组合在一起。 在下一篇文章中,我们将开始讨论着色器排列,并试图找到减少重新编译着色器所需时间的方法,这样您就可以花更少的时间等待,而且可以花更多的时间进行编码!

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值