透明渲染乱序问题
大部分实时引擎的透明渲染的排序是逐物体的,而非逐像素的,这会导致模型间存在交叉的情况下可能会出现透明渲染乱序问题,再加上模型的顶点顺序是制作时序列化固定的,这会使得凹形的单模型在某些角度下也会出现渲染乱序问题。
凹形的单模型可看做是多个交叉模型的组合,它们的透明渲染乱序问题本质上是同一个问题。
目前的解决方式
UE4目前提供的解决透明渲染乱序问题的方式是:透明Actor先写一遍Custom Depth,然后在Base Pass中通过深度比较把深度值比Custom Depth大的Fragment的颜色设成0,这样只保留了离摄像机最近的最外层的Fragment颜色。
显然这种丢弃掉模型里层颜色的方式得到的透明渲染结果是不正确的,正确的渲染方式应该是保留模型所有的Fragment颜色,按离摄像机由远到近的顺序依次做透明颜色混合,这就是本文要实现的DepthPeeling(深度剥离)。
Depth Peeling原理
引用文献[2]
Depth peeling is the underlying technique that makes this approach for orderindependent transparency possible. The standard depth test gives us the nearest fragment at each pixel, but there is also a fragment that is second nearest, third nearest, and so on. Standard depth testing gives us the nearest fragment without imposing any ordering restrictions, however, it does not give us any straightforward way to render the second nearest or nth nearest surface. Depth peeling solves this problem. The essence of what happens with this technique is that with n passes over a scene, we can get n layers deeper into the scene. For example, with 2 passes over the scene, we can extract the nearest and second nearest surfaces in a scene. We get both the depth and color (RGBA) information for each layer.
Depth Peeling算法基于多次渲染,主要步骤为:
1.首先正常绘制模型,开启深度测试,获取最外层的Fragment颜色和深度。
2.第二次绘制模型使用第一次获取的深度信息做深度测试,获取次外层的Fragment颜色和深度。
3.重复步骤2,每次绘制用上一次获取的深度信息做深度测试,获得所有里层Fragment的颜色,最后从里向外透明混合所有层颜色获取最终颜色。
理解Depth Peeling原理之前需清楚Fragment和Pixel的区别:模型光栅化后会有跟屏幕Pixel位置对应的Fragment计算,一般情况下,Pixel与Fragment是1对多的关系,Pixel的最终颜色是所有Fragment颜色混合的结果。如下图示意的摄像机投影面Pixel点P对应着4个Fragment点ABCD:
实现步骤简述
1.通过ASceneCapture2D来获取透明Actor的颜色和深度信息:
SceneCaptureComponent2D->PrimitiveRenderMode = ESceneCapturePrimitiveRenderMode::PRM_UseShowOnlyList;
SceneCaptureComponent2D->ShowOnlyActors.Add(OITActor);
SceneCaptureComponent2D->CaptureSource = ESceneCaptureSource::SCS_SceneColorSceneDepth;
SceneCaptureComponent2D->bCaptureEveryFrame = false;
其中,PrimitiveRenderMode 置成PRM_UseShowOnlyList,单独绘制透明Actor。ESceneCaptureSource置成SCS_SceneColorSceneDepth。因为需要在一帧内手动控制ASceneCapture2D进行多次绘制,bCaptureEveryFrame置成false。
2.创建跟Peeling层数对应的UTextureRenderTarget2D:
for (int32 Index = 0; Index < PeelingNum; Index++)
{
if (!RT_ColorDepths.IsValidIndex(Index))
{
// Create RT_ColorDepth
FName Name = *FString::Format(TEXT("RT_ColorDepth_{0}"), { Index });
UTextureRenderTarget2D* RT_ColorDepth = NewObject<UTextureRenderTarget2D>(this, Name);
RT_ColorDepth->ClearColor = FLinearColor::Transparent;
RT_ColorDepths.Add(RT_ColorDepth);
}
RT_ColorDepths[Index]->InitCustomFormat(ViewportSize.X, ViewportSize.Y, PF_FloatRGBA, false);
其中,PeelingNum为Peeling层数,创建好的UTextureRenderTarget2D按Viewport窗口大小初始化好,按绘制顺序保存在RT_ColorDepths队列中。
3.每次绘制获取的颜色和深度信息用对应的UTextureRenderTarget2D来保存:
// Capture color and copy to RT_Color
SceneCaptureComponent2D->CaptureScene();
UTextureRenderTarget2D* RT_TextureTarget = SceneCaptureComponent2D->TextureTarget;
UTextureRenderTarget2D* RT_ColorDepth = RT_ColorDepths[Index];
ENQUEUE_RENDER_COMMAND(FCopyToRT_ColorDepth)(
[RT_TextureTarget, RT_ColorDepth](FRHICommandListImmediate& RHICmdList)
{
check(IsInRenderingThread());
RHICmdList.CopyToResolveTarget(
RT_TextureTarget->GetRenderTargetResource()->GetRenderTargetTexture(),
RT_ColorDepth->GetRenderTargetResource()->GetRenderTargetTexture(),
FResolveParams());
RHICmdList.ImmediateFlush(EImmediateFlushType::DispatchToRHIThread);
});
FlushRenderingCommands();
其中,在Tick中手动调用USceneCaptureComponent2D::CaptureScene()之后,需在渲染线程把TextureTarget内容拷贝到对应的UTextureRenderTarget2D中,因为当次绘制获取的深度信息需要用于下次绘制的深度剔除,所以添加FlushRenderingCommands保证渲染线程的RT拷贝操作立刻执行,保证接下来操作的同步性。
另外,注意要设置Tick()时机SetTickGroup(TG_PostUpdateWork)来使得CaptureScene()在所有逻辑帧更新完之后执行,不然会碰到第一帧抓不到内容,RT内容获取也不正常的情况。
4.绘制前需替换Actor的原有透明材质,替换的材质用于颜色输出和深度剔除:
因为UE4不支持动态切换材质的BlendMode,所以这里用替换材质的方式来实现。替换模型材质绘制这种做法很像Unity的RenderWithShader,但没有Unity那么简洁,因为替换材质可能会影响正常渲染,所以SceneCapture抓完之后得换回正常材质。
其中,替换材质的BlendMode为Masked,用PixelDepth和上一次获取的RT深度信息比较来做Mask剔除。
PixelDepth和RT的深度信息变化规律是离摄像机越远值越大,所以要获取模型里层的Fragment需用满足PixelDepth大于RT_Depth的条件来保留,否则剔除。保留里层需去掉当前最外层,当前最外层Fragment的PixelDepth与RT_Depth相等,给RT_Depth增加一个小Bias使得PixelDepth肯定小于RT_Depth+Bias,从而被剔除。
Bias值在实际项目中可能需要动态调整,太小的话离摄像机远了会有Z-Fighting现象。太大的话模型面衔接位置会有接缝(有Fragment写入但离当前最外层太近被剔除)。
上图用到的测试模型为题图所示的包含一个小立方体的中空圆环柱体,开启了双面材质使其透明渲染层次更加明显。
另外注意一下,如果用SCS_SceneDepth方式来获取深度信息,变化规律是离摄像机越远值越小,拿它跟片段的PixelDepth比较是得不到正确结果的。
5.在后期材质里混合所有Peeling层对应的UTextureRenderTarget2D:
上图展示的是场景背景颜色依次混合3层Peeling颜色的后期材质。场景背景颜色与Peeling最里层开始做透明混合,依次与外层做混合叠加得到最终颜色。
材质中Peel_Blend节点的Shader实现为:
// Alpha Blend
half3 Color = lerp(pow(Above, 0.45), Below, 1 - Alpha);
// Above Only
half3 AboveOnly = step(0.001, Above) * Color;
// Above Clipped
half3 AboveClipped = step(0.999, 1 - Above) * Below;
// Add
return AboveOnly + AboveClipped;
其中,Peeling层的透明混合跟正常的透明混合稍有不同:因为使用公用透明度的原因(文后会解释),正常的透明混合SrcColor*SrcAlpha+DestColor*(1-SrcAlpha)在外层贴图覆盖范围内是对的,没覆盖到(RT颜色纯黑)的部分DestColor*(1-SrcAlpha)变暗了,得到的结果是整个里层被压暗了,所以需要按外层覆盖与否分开处理。
最终效果对比
从对比图可看UE4默认的Translucent渲染结果有明显的乱序问题,Custom Depth方式的渲染结果只保留了模型的最外层颜色信息,Depth Peeling方式得到了正确的渲染结果。
上两张图采用的Deep Peeling方式均使用了5层Peeling,Peeling层数决定了重复绘制透明Actor多少次。理论上绘制次数跟最大Fragment数量一致可以得到最正确的渲染结果,但实际应用中没必要,大于4层效果区别已经不大,见参考文献[2][3]。
完整Demo工程(4.26.0版本):
chenyong2github/UE4_OITDemogithub.comPS:由于UE4的ASceneCapture2D不支持MRT、透明渲染不写深度和不透明渲染不写Alpha值等原因,Depth Peeling实现起来局限性还是有点大,使用公用透明度也是因为这些原因。
另外,OIT的方法除了Depth Peeling,还有顶点索引排序和Per-PixelLinkedLists等方法,从整体效果和移动端兼容性角度来说Depth Peeling还是一个不错的方法,当然Depth Peeling还有很多效率优化版,比如Dual Depth Peeling,Shortcut等,这里就挖个坑,抛块砖,Enjoy~~~
参考文献:
[1]https://docs.aws.amazon.com/lumberyard/latest/userguide/graphics-rendering-order-independent-transparency.html
[2]https://my.eng.utah.edu/~cs5610/handouts/order_independent_transparency.pdf
[3]https://developer.download.nvidia.cn/assets/gamedev/docs/OrderIndependentTransparency.pdf