【转载】UE4 渲染基础 - 实例:场景亮度的获取与使用

994 阅读12分钟

原文链接:[《UE4渲染基础 - 实例:场景亮度的获取与使用》| 鲨莉叶 ](zhuanlan.zhihu.com/p/406025871)

—— 以具体功能为例,一窥 Unreal 的渲染过程。

任务目标:

在后处理线程中添加一个处理当前视野内场景亮度的过程,输出场景平均亮度,并且游戏线程(蓝图)可以依据该数值进行 Gameplay 的设计

任务结算技能点:

  • 后处理渲染流程 ++
  • ComputeShader +
  • UE 引擎架构 +

任务流程:

渲染线程获取场景亮度 > 计算场景亮度数值 > 通过接口使游戏线程可以调用

和任务需求最匹配的,且 UE 已有的功能就是眼部适应,即通过检测场景亮度调节曝光,我们需要的正是其中的检测场景亮度部分,因此 核心思路 就是直接借用 UE 的实现,省去了造轮子的麻烦和可能造成的破坏性改动。

EyeAdaptation 在 UE4 中的实现

首先在源码中定位 EyeAdaptation 部分的代码: PostProcessEyeAdaptation.cpp 文件 AddHistogramEyeAdaptationPass 函数。

定位某个渲染功能可以通过断点、堆栈找到主渲染函数 Render(),再善用 Ctrl+F 查找关键字,一路 F12 沿着功能的细分查找下去。比如 EyeAdaptation 就可以在渲染过程中随便断点,找到 Render() -> GPostProcessing.ProcessES2() -> AddHistogramPass()

移动端与 PC 端的渲染入口分别在 MobileShadingRenderer.cppDeferredShadingRenderer.cppRender() 函数 在参数中可以找到一张输入图像:HistogramTextureHistogram直译即直方图,这就是我们需要得到的目标 —— 场景亮度直方图

场景亮度直方图 ?

在 ES5 预览渲染级别下,可以通过视口左上角的 显示 - 可视化 - HDR(眼部适应) (Show - Visualization - HDR (Eye Adaptation)) 打开可视化界面

image.png

屏幕最下方的这张图表就是所谓 场景亮度直方图,顺着 HistogramTexture 参数的传递找到渲染该直方图的函数 AddHistogramPass() 。通过阅读 AddHistogramPass() 中使用的 ComputeShader(将在下文详述),即 FHistogramCS,我们可以了解到这张直方图的含义:横轴为屏幕中像素亮度的区间,纵轴为落到该区间的像素比例,可以简单解读为:场景中像素亮度值的分布。

这张图是如何渲染的 ?

这其实是得出上文结论之前的步骤,只是因为行文的方便,将结论放在前面。

要找到渲染方式,只需要找到着色器文件即可,一般来说按顺序查找几个标志就能快速定位: 找到 AddPass() 系列函数就能找到一个 Shader 指针,在指针的声明中可以找到 TShaderMapRef<typename ShaderType>,最后通过 ShaderType 找到标志性的 IMPLEMENT_SHADER 系列的宏就可以找到所在的 .usf 文件和着色器入口函数。

image.png

找到着色器和函数,可以开始阅读了,就进入了下一个关于 ComputeShader 小主题:

image.png

ComputeShader 的原理与使用

ComputeShader(下文简称 CS)的理论介绍有很多很详尽的文章,因此这里的重点是结合 Histogram 的实例进行分析,如果有不够深入的地方,可以再去针对性的查阅。

RenderDoc 的输出里,这张直方图其实是这个样子的:

  • 第一个 ComputeShaderPass 的结果

image.png

  • 经过一个 PixelShaderPass 的处理降为 16x2

image.png

  • 图1

image.png

和常见的 VertexShaderPixelShader 第一眼看上去结构是一样的,但是有些细节不同:

1. 线程组维度(绿色)

一个 CS 中的 Dispatch 是一个三维的容器,每一个容器中是一个 ThreadGroup,而每一个 ThreadGroup 同样是一个三维的容器,每一个容器中是一个 Thread ,可以想象一个大盒子中嵌套的小盒子,三个维度分别是盒子的长宽高,当我们需要指定一个盒子的时候可以说在第 z 层(高)从上往下数第 x 排(宽)从左往右数第 y 个(长),这就是线程/线程组 ID 的概念,一个描述位置的量。

2. 线程 Id 与 Index(青色)

Dispatch[x,y,z] - Group[a,b,c] - Thread 构成了一个三级结构

  • SV_GroupThreadID [a,b,c]
  • SV_GroupID [x,y,z]
  • SV_DispatchThreadID [(x,y,z)*(X,Y,Z) + (a,b,c)]
  • SV_GroupIndex (c*X*Y)+(b*X)+(a)

这个解释可能还不够直观,翻译为自然语言就是:(这里为了描述方便,采用了相对位置和绝对位置的概念,并不严谨

  • SV_GroupThreadID 表示该线程在该线程组内的相对位置
  • SV_GroupID 表示线程组Dispatch 中的位置
  • SV_DispatchThreadID 表示该线程所有线程中的绝对位置
  • SV_GroupIndex 表示该线程在该线程组内的索引

索引即将坐标一维化,也可以理解为编号

3. 线程组内共享内存 groupshared(黄色)

本例中 CS 声明了一个三维数组 SharedHistogram,它的作用是储存该线程组内所有线程的计算结果。

该数组的三个维度分别是:

  • HISTOGRAM_SIZE: 直方图的大小,即可视化中看到的横轴——有多少个等分的亮度阶段
  • THREADGROUP_SIZEXTHREADGROUP_SIZEY: 一个线程组的前两个维度大小。

这个共享内存的功能通过理解线程计算主体的逻辑可以得到,在这里先说明结论:

SharedHistogram 储存的是 亮度-线程 的检索表,储存的是该亮度阶段、该线程有多少权重。即,在 HISTOGRAM_SIZE 个等分的亮度阶段(图表的横轴)中,该线程所处理的 LOOP_SIZEX * LOOP_SIZEY 个像素分别落入哪个像素阶段中,且权值(百分比)如何(更靠近哪个阶段端点)。

举一个具象的例子,一个 ID 为(2,3,0) 的线程处理了 8x8 共计 64 个像素,这个线程的像素亮度数据都将存入SharedHistogram[n][2][3] 这系列的数组中,可以理解成从一个三维数组中抽出了一行,一个一维数组,我们将这个一维数组暂时称为 subSharedHistogram[n]。假设一个像素的亮度为 0.75 ,我们将亮度均分为 64HISTOGRAM_SIZE)份,那么这个亮度就应该在 4.8 的位置上,就像在做直方图一样,4.8 落在 4-5 之间,我们就将它存放在数组的这两个位置中,通过分离小数位 0.8,将 0.8 存放在 subSharedHistogram[5] 中、将 1-0.8=0.2 存放在 subSharedHistogram[4] 中,一个像素的亮度就被存放在直方图中了。

接下来只要在线程中逐个像素计算、以同样方式计算所有线程,就可以将全屏像素用上述方式处理成为一张图表存入 SharedHistogram 中。

  • 像素计算部分逻辑 image.png

4. 同步线程共享内存 GroupMemoryBarrierWithGroupSync(紫色)

通过该命令强制将线程同步,确保清理内存和运算能够在数据完整的状态下进行。

  • 图 2

image.png

在图 2 中,经过线程同步后将每相邻的 4 个亮度阶段整合在 1 个像素中,写入一张 U 为亮度阶段、V 为各个线程的图像,就得到了第一个 ComputeShaderPass 的结果。

第二个降采样 PS 结构比较简单,也不是叙述重点,因此在这里就简单带过:

image.png

将所有线程的颜色叠加,再除以线程数得到均值,也就是将第一张图的每一列都取均值,最终输出一张(1/4 亮度阶段 x 1)像素的图片,这里输出的图片多了一行是因为储存了上一次运算出的ExposureScale,用作眼部适应的渐变。

渲染结果的获取与使用

后处理渲染流程

了解了 Shader 之后我们再回过头阅读 C++ 的 Pass 逻辑,这部分主要是为了着色器的使用做支持,就像我们仅仅掌握绘画技法仍然无法作画,我们需要画布画笔以及绘画步骤等等,这些部分被放在渲染管线的每一个 Pass 之中,本例中就以 AddHistogramPass() 函数为核心,一窥 Unreal 后处理的渲染流程。

我们首先从局部看起,从明确的代码过度到较为抽象的概念比较符合我个人在分析时的思路,想要直接阅读获取渲染结果方式请转到下一小节

AddHistogramPass()主要分为几个部分:

  1. 参数的输入
  2. 渲染目标的指定
  3. 添加渲染指令
  • AddHistogramPass(前半)

image.png

AddHistogramPass 前半部分为例(后半部分为降采样 Pass),将 Pass 过程分成几个区块以后,就可以根据区块分别向下去了解更细致的内容,比如如何为着色器增加绑定的参数:就可以进入参数输入部分找到着色器参数输入结构体 FHistogramCS::FParameters,了解着色器类的构造:

  • HistogramCS(片段)

image.png

这些操作和内部构造都是有共性的,在了解渲染管线理论知识的基础上,就可以很快将具体的代码拼合到宏观的流程之中。

当然有些内容是不深入到实践之前很难了解到的,比如在初次接触时可能会疑惑 ShouldCompilePermutation 的作用、RDG 是什么,或是想要了解参数是如何绑定并且输入到着色器的过程,这些部分会在接触更多同类代码和管线的细节之后建立模式化的认识。因为涉及到资源的组织、代码的优化等等因素,引擎内管线的复杂度比单纯的渲染管线理论所介绍的‘管线’要复杂许多。

回到 AddHistogramPass 层级,向上查找该函数的调用则进入 AddPostProcessingPasses 层级,也就是添加全部后处理 Pass 的函数,在这一层级,我们可以看到 Pass 的输入结构、 Pass 之间的资源调度、各个 Pass 的先后顺序等。

  • AddPostProcessingPasses(片段)

image.png

再向上一层就到达了 Render() 层,也就是渲染整体的顺序层次,是一个 1k+ 行非常庞大的函数,可以看到更宏观的渲染过程,因为步骤太多而且没有明确的规律,这里就不列举截图了,只要在需要的时候通过这个函数去找具体的调用就可以了。

如何得到一个数值而非图像?

当我们通过 HistogramPass 得到了这张被降采样后的场景亮度图,接下来的步骤就与渲染过程无关了,我们只需要在代码里读取这张图的数据,并最终计算即可。

读取贴图

第一个步骤又可以细分为三个步骤:

  1. 获取图像指针
  2. 读取图像内存
  3. 输出图像内存

其中读取图像内存是起关键作用的步骤,另外两个步骤都是帮助它完成工作。在这个例子中,读取图像内存主要使用的是 LockTexture2D 函数,需要传入一个 FRHITexture2D 指针作为参数。一开始可能会比较感到困惑的点是,如何将 Pass 输出的 FRDGTextureRef 转换成 FRHITexture2D 指针,这二者很难直观地看到转换方式,也没有直接或间接的继承关系。

先说结论:

通过以下代码

PixelShaderParameters->
RenderTargets[0].GetTexture()->
GetPooledRenderTarget()->
GetRenderTargetItem().ShaderResourceTexture

得到 FTextureRHIRef,也就是指向一张 FRHITexture 的(计算引用次数的)贴图,但无法描述贴图格式(指贴图是 TextureArray2D3D 的格式)

通过以下代码

static_cast<FRHITexture2D*> (RDGTextureRef.GetReference())

直接转换格式至 Texture2D 类型。

这部分推理过程比较啰嗦,就不展开讲了,推理的核心思路就是拆解类和 typedef 的嵌套,寻找相同的结构以及转换方式,虽然可能自己推理会花更多时间,但能够加深对于系统的理解和熟悉,还是非常值得的。

  • 推理的部分笔记

image.png

这部分会接触到 RHI 层的封装,也理清了关于引擎适配不同 API 的一些思路。

第一步骤获取指针需要注意的就是在 Execute() 运行之前获取 RHITexture2D 的指针,否则会有引用解绑,获取不到的问题。

第三步 输出图像内存的方式参考了知乎上关于读写 Texture2D 的文章 YivanLee:虚幻4渲染编程(Shader篇)【第六卷:资源操作】,使用位操作将 RGB 值分别输出到数组中,供之后的数据处理。

image.png

计算亮度

最后关于具体亮度数值的计算,UE 已经有了非常好的参考,从使用的角度来说只需要改写 UE 原装的 EyeAdaptation 即可,这段计算可以在 PostProcessHistogramCommon.ush 中找到: ComputeEyeAdaptationExposure() 函数,输入值 PostProcessEyeAdaptation.cppAddHistogramEyeAdaptationPass() 函数中找到具体获取途径。

从思路上来说,先是对直方图全部亮度阶段求和,将百分比(LowPercentHighPercent)所指定的过亮与过暗区域剔除,再除以像素数量求像素均值即可。

获取亮度的过程到此就告一段落,接下来需要使这个值能够在蓝图中被获取,在这个过程中比较困惑的地方就是关于渲染线程和游戏线程的关系。

渲染线程与游戏线程

关于多线程的处理,往往是一件很容易踩坑的地方,主要是由于线程同步、线程本地内存、读写安全之类的,但实际上在本例中,游戏线程的只读操作不会干涉渲染线程的写入操作,单个数值的写入本身就是原子操作,不会被打断也不会读到脏数据,因此使用静态变量即可。

为此,选择一个暴露给项目的引擎类,声明一个静态变量,并且包装读写函数,就可以在项目代码中使用自定义的蓝图可调用函数进行使用了。

关于线程的启动和调用,看调用栈仍然是比较有效的方式粗略掌握运行过程:

WindowsRunnableThread.h中 的 _ThreadProc 是线程的总入口,通过 Run() 将各个 FRunnableThreadWin 实例跑起来,线程中的任务被称为 Task,由 TaskGraph 进行管理,不同类型的 Task 都通过 Task->ExecuteTask() 这个方法去执行队列里的任务。其中 GraphTask<TTask> 是包装 Task 的外层,真正的 Task 执行是在 TTask.DoTask() 部分,在 ExecuteTask 函数中断点查看 CurrentThread 可以追踪特定线程。

在渲染功能的开发里,这方面内容平时接触的机会比较少,但是也能够补全引擎架构方面认知,从更宏观的角度了解引擎。

待补充:

分帧机制

前向渲染下手电光照的反向剔除

最后,因为我本人也是一边学习一边写的笔记,不可避免地会出现一些错误,如果有大佬看到希望可以评论斧正,有想要一起探讨的同学也非常欢迎评论联系我!