@SardineFish
次序无关的半透明渲染实现 (Order-independent transparency,OIT)
实时渲染中,实现半透明渲染的通常做法是将在场景不透明物体完成渲染之后,对场景中半透明物体到摄像机的距离进行排序,从距离摄像机最远的(Z值最大)的物体开始逐个叠加渲染,并在渲染中与输出缓冲中的原有颜色进行混合叠加。对于 2D 半透明渲染,这样的实现是足够的,而在 3D 场景中,由于排序基于物体的轴点位置,渲染时会出现以物体为单位的覆盖效果,例如物体间循环覆盖的效果,就无法被正确的渲染。此外对于一些自身具有复杂结构的半透明物体,自身三角形的渲染次序也会影响画面的观感。
次序无关的半透明渲染实现-知乎 - 图1中:正确的渲染效果,左:关闭深度写入,右:开启深度写入和深度测试次序无关的半透明渲染实现-知乎 - 图2右图的克莱因瓶本应该在瓶内的部分被渲染成了在外部的样子

本文将介绍一种次序无关的半透明渲染算法,并将基于 Scriptable Render Pipeline 在 Unity 中实现。
本文所需要的前置知识包括:

  • GPU 渲染管线基础知识
  • Unity 渲染开发
  • SRP 开发

如果读者对编写自定义 SRP 渲染管线感到迷惑,可以参考 Unity 的 URP(前身 LWRP)的实现代码(链接失效)
或是参考我的一篇博客。一个最简单的SRP
文内的代码均经过简化,无法直接运行,详细的实现放在了 GitHub (tag: depth-peeling) SimpleSRP

About Depth Peeling

深度剥离是一种次序无关的半透明渲染算法,由 Nvidia 提出。其基本思想是利用 N 个 Pass 分别渲染出距离摄像机第 N 近的片元,这即是深度剥离的过程,最后再从远到近依次将剥离出的 N 个图层根据透明度混合叠加到屏幕缓冲中。
具体可以参考 .gamedevs.org/uploads/inte
次序无关的半透明渲染实现-知乎 - 图3图中的克莱因瓶外表面为红色,内表面为蓝色。pass1 渲染得到完整的外表面,pass2 得到了内表面和在瓶内的瓶颈的外表面,pass3 得到瓶内的瓶颈的内表面,pass4 则是被瓶内瓶颈遮挡的内表面次序无关的半透明渲染实现-知乎 - 图4上图是一个从左到右的深度剥离过程,图中黑色加粗的部分是当前剥离层所渲染的表面层,黑色细线表示被遮挡的表面,灰色表示已经被剥离的表面。

实现深度剥离中的一个 Pass 需要2个深度缓冲和1个颜色缓冲,其中一个深度缓冲储存上一层剥离的深度值,另一个储存当前剥离层的深度值,颜色缓冲储存当前剥离层的片元色彩。
可以用以下伪代码表示(假设片元深度 depth 为从近到远的 [0, 1] 取值范围)

  1. buffer colorBuffer[N];
  2. buffer depthBuffer[N];
  3. buffer cameraTarget;
  4. // Call for N pass peeling
  5. void depthPeeling(frag input, int pass)
  6. {
  7. if(pass == 0) // first pass
  8. {
  9. if(depth < depthBuffer[pass])
  10. {
  11. depthBuffer[pass] = depth;
  12. colorBuffer[pass] = color;
  13. }
  14. }
  15. else
  16. {
  17. if(depth >= depthBuffer[pass - 1] && depth < depthBuffer[pass])
  18. {
  19. depthBuffer[pass] = depth;
  20. colorBuffer[pass] = color;
  21. }
  22. }
  23. }
  24. // Call for final color blend
  25. void colorBlend()
  26. {
  27. for(i = 0 -> N)
  28. {
  29. cameraTarget = blend(colorBuffer[i], cameraTarget);
  30. }
  31. }

SRP Implement

这里我们将深度剥离放在场景中的不透明物体渲染完成之后进行,由于 Unity 不支持多个深度缓冲,并且无法通过任何途径读取深度缓冲中的数据(如果有,请务必告诉我!!)
这里我们可以基于 Multi Render Target,用一个 RFloat 格式的 TemporaryRT 来保存我们需要的深度值。
(以下代码经过简化,其中的 context.DrawRenderers 会使用指定的 Pass 渲染场景中的所有半透明物体)

  1. List<int> colorRTs = new List<int>(N);
  2. List<int> depthRTs = new List<int>(N);
  3. // Perform depth peeling
  4. for (var i = 0; i < N; i++)
  5. {
  6. cmd.GetTemporaryRT(colorRTs[i], width, height, 0);
  7. cmd.GetTemporaryRT(depthRTs[i], width, height, 32, FilterMode.Point, RenderTextureFormat.RFloat);
  8. if (i == 0)
  9. {
  10. cmd.SetRenderTarget(new RenderTargetIdentifier[] { colorRTs[i], depthRTs[i] }, depthRTs[i]);
  11. context.ExecuteCommandBuffer(cmd);
  12. drawingSettings.SetShaderPassName(0, new ShaderTagId("DepthPeelingFirstPass"));
  13. context.DrawRenderers(/* ... */);
  14. }
  15. else
  16. {
  17. cmd.SetGlobalTexture("_MaxDepthTex", depthRTs[i - 1]);
  18. cmd.SetRenderTarget(new RenderTargetIdentifier[] { colorRTs[i], depthRTs[i] }, depthRTs[i]);
  19. context.ExecuteCommandBuffer(cmd);
  20. drawingSettings.SetShaderPassName(0, new ShaderTagId("DepthPeelingPass"));
  21. context.DrawRenderers(/* ... */);
  22. }
  23. }

这里需要注意的是,我们作为深度储存的 depthRT 必须为 FilterMode.Point 的格式,因为我们希望为每一个屏幕像素储存和获取精确的深度值,而不是经过采样插值的深度值,这里如果使用了非临近采样的格式,在每个 Pass 中丢弃与上一 Pass 同一深度值的片元时会由于采样到的非精确的深度值而出现异常的效果。

Shader Code

由于使用了 Multi Render Target。我们需要在同一个 Fragment Shader 中输出到多个 Render Target,我们在这里定义一个输出结构,通过声明语义 SV_TARGETX 将对应的字段输出到第 X 个 Render Target。

  1. struct DepthPeelingOutput
  2. {
  3. float4 color : SV_TARGET0;
  4. float depth : SV_TARGET1;
  5. }

深度剥离的首个 Pass 和其余 Pass 的 Shader 代码如下:

  1. // ZWrite On
  2. // ZTest LEqual
  3. DepthPeelingOutput depthPeelingFirstPass(v2f i) : SV_TARGET
  4. {
  5. DepthPeelingOutput o;
  6. o.color = renderFragment(i);
  7. o.depth = i.pos.z;
  8. return o;
  9. }
  10. // ZWrite On
  11. // ZTest LEqual
  12. sampler2D _MaxDepthTex;
  13. DepthPeelingOutput depthPeelingPass(v2f i) :SV_TARGET
  14. {
  15. i.screenPos /= i.screenPos.w;
  16. float maxDepth = tex2D(_MaxDepthTex, i.screenPos.xy).r;
  17. float selfDepth = i.pos.z;
  18. if(selfDepth >= maxDepth)
  19. clip(-1);
  20. DepthPeelingOutput o;
  21. o.color = renderFragment(i);
  22. o.depth = i.pos.z;
  23. return o;
  24. }

Final Pass

完成深度剥离后我们还需要 N 个 Pass 将这 N 层渲染的颜色值按从后到前的顺序与场景图像混合。

  1. cmd.SetRenderTarget(BuiltinRenderTextureType.CameraTarget, BuiltinRenderTextureType.CameraTarget);
  2. var mat = new Material(Shader.Find("Transparent/DepthPeeling"));
  3. for (var i = N - 1; i >= 0; i--)
  4. {
  5. cmd.SetGlobalTexture("_DepthTex", depthRTs[i]);
  6. cmd.Blit(colorRTs[i], BuiltinRenderTextureType.CameraTarget, mat, 4);
  7. }
  8. context.ExecuteCommandBuffer(cmd);

为了能正确渲染不透明物体和半透明物体之间的遮挡关系,我们还需要将每一个剥离层的深度值用于与摄像机深度缓冲进行深度测试,或者根据需要开启深度写入。
我们可以在 Fragment Shader 中加入 SV_DEPTH 语义实现自定义的深度值输出,Shader 代码如下

  1. // ZTest LEqual
  2. // Blend SrcAlpha OneMinusSrcAlpha
  3. sampler2D _DepthTex;
  4. float4 finalPass(v2f i , out float depthOut : SV_DEPTH) : SV_TARGET
  5. {
  6. float4 color = tex2D(_MainTex, i.uv);
  7. float depth = tex2D(_DepthTex, i.uv);
  8. clip(depth <= 0 ? -1 : 1);
  9. depthOut = depth;
  10. return color;
  11. }

Results

使用深度剥离可以避免在透明渲染时对物体进行排序,同时对复杂的半透明物体有更好的渲染效果,最终效果取决于深度剥离的 Pass 数量。由于 N 个 Pass 只能渲染距离摄像机第 N 近的半透明表面,而第 N 层之后的内容被直接丢弃了。在实现效果上,4~6个 Pass 已经可以实现足够好的渲染效果。
次序无关的半透明渲染实现-知乎 - 图5次序无关的半透明渲染实现-知乎 - 图6

Optimize

使用 N 个 Pass 渲染场景中所有的半透明物体是一个不小的性能消耗,最坏的情况下我们需要对场景中所有的半透明表面渲染 N,如果要额外实现菲涅尔反射、折射等复杂的表面效果时,所带来的性能消耗是巨大的。
双深度剥离
Nvidia 提出了一个可以在一次 Pass 中剥离两层的算法,即每一次剥离出距离摄像机第 i 近和 第 i 远的两层,这样可以用 (N/2+1) 个 Pass 实现 N 层深度剥离的效果。具体可以参考/developer.download.nvidia.com/
而实现两层剥离需要 GPU 支持双深度缓冲,目前 Unity 尚未支持。
延迟表面渲染
此外我们还可以使用类似 Deferred 的方式对半透明表面进行复杂着色, 即在每次剥离时除了深度值以外,我们还需要储存当前层的法线、颜色等数据,在最终的混合 Pass 中再进行半透明表面的复杂着色渲染。而这样我们需要类似 N 个 GBuffer 的空间,以空间换时间的目的。
合并最后一层
对于 N 层剥离之后被丢弃的半透明表面,我们可以在剥离最后一层时,使用传统的半透明渲染方法,关闭深度测试,仅丢弃已经在前几个 Pass 中被剥离掉的的表面,而将后面所有无法剥离的半透明物体渲染在同一个层上。

Code

具体的实现代码放在了 GitHub (tag: depth-peeling):SimpleSRP

References

Everitt, Cass. “Interactive order-independent transparency.” White paper, nVIDIA 2.6 (2001): 7.
Bavoil, Louis, and Kevin Myers. “Order independent transparency with dual depth peeling.” NVIDIA OpenGL SDK (2008): 1-12.

另一个知乎专栏
https://zhuanlan.zhihu.com/p/78774339