半透明渲染排序問題 長期在各種3d引擎存在,這裡將一些針對性技巧。因為通用的解決方案 已經有各種OIT專業方案,比如 gpu 鍊表 和 depth peeling。但還是那個普遍規律越通用的性能就越不夠理想 因為他們不知道你資源的特殊性 可以做哪些極端的簡化。所以這裡只分享些個人針對一些具體開發積累的小小技巧(持續補充中...)。
對於 為什麼會出現這個問題本質原因還不清楚的 可以看這篇基礎說明(文章底部參考閱讀). 理解這個基礎原因很有好處,否則一堆美術問你為什麼max maya 沒問題 unity ue有問題的時候 你不知道怎麼回答。更直接的說是2個原因:
1 半透明混合的算法 是 srcAlpha oneMinusSrcAlpha 這個公式是 前後不對稱的 所以嚴重依賴渲染順序(這也是特效的翅膀半透明模型為什麼沒深度問題 特效大量用 one one混合 這是前後色對稱的 無關順序的);
2 引擎的半透明排序是逐對象的,離線渲染工具是逐像素的所以沒問題;
所以接下來針對這2個修改 實現些高性能方案。
深度寫入的alphaBlend(固定深度法)案例:這是針對頭髮這種需求,大量中心部分不透明,邊緣透明。
原理:先在不透明區域繪製一遍不寫顏色的深度,來強制實行這些像素半透明材質的染順序正確(因為本被遮擋 有可能渲染到前面的半透明 因為這層深度寫入 會絕對剔除 不可能再有機會渲染到前面 產生亂序),因為這部分是模型的絕大部分區域 所以可以確保絕大部分區域的效果正確。但是半透明邊緣遮擋半透明邊緣的時候 就無法確保正確了 因為 上面這套深度圖 不包含這些區域,好在這些區域很小所以可接受 實際效果圖如下(為了簡化主題 剝離了頭髮專有的高光計算)。
全部alphatest效果 邊緣不柔和
全部 alpahblend效果 邊緣柔和但深度錯誤
該方案效果 深度大部分正確+邊緣柔和
Shader "Hidden/Advanced Hair Shader Pack/Aniso_Opaque" { Properties{ _MainTex("Diffuse (RGB) Alpha (A)", 2D) = "white" {} _Color("Main Color", Color) = (1,1,1,1) _Cutoff("Alpha Cut-Off Threshold", float) = 0.95 } SubShader{ Tags{ "Queue" = "AlphaTest" "RenderType" = "TransparentCutout" } Blend Off Cull off ZWrite on colormask 0
Pass { Name "OPAQUE" CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc"
struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; };
struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; };
sampler2D _MainTex; float4 _MainTex_ST; half4 _Color; half _Cutoff; v2f vert(appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); return o; }
fixed4 frag(v2f i) : SV_Target { fixed a = tex2D(_MainTex, i.uv).a*_Color.a; clip(a - _Cutoff); return 0; } ENDCG } } }Shader "Advanced Hair Shader Pack/Aniso_Transparent" { Properties{ _MainTex("Diffuse (RGB) Alpha (A)", 2D) = "white" {} _Color("Main Color", Color) = (1,1,1,1) _Cutoff("Alpha Cut-Off Threshold", float) = 0.95
} SubShader{ UsePass "Hidden/Advanced Hair Shader Pack/Aniso_Opaque/OPAQUE"
Cull off ZWrite off
Tags{ "Queue" = "Transparent" "IgnoreProjector" = "True" "RenderType" = "Transparent" "ForceNoShadowCasting" = "True" }
CGPROGRAM
#include "Lighting.cginc" #pragma surface surf Lambert alpha:auto #pragma target 3.0
struct Input { float2 uv_MainTex; float3 viewDir; }; sampler2D _MainTex; float _Cutoff; fixed4 _Color; void surf(Input IN, inout SurfaceOutput o) { fixed4 albedo = tex2D(_MainTex, IN.uv_MainTex); o.Albedo = albedo.rgb*_Color.rgb; o.Alpha = saturate(albedo.a / (_Cutoff + 0.0001)); clip(albedo.a - 0.0001); }
ENDCG
}
}unity 半透明渲染技巧(2):預計算順序法
前一篇介紹了問題的原因和可入手的2點,這次依然從渲染順序入手,這裡是前一篇連結.
案例:上一個方案中不適合中間需要大量透明的效果否則邊緣很生硬看如下對比圖。
固定深度方案 遇到非邊緣區域大面積半透明 效果不好
本方案 預計算順序法 效果更好
原理:既然引擎只排序到逐對象級別,那麼對象內渲染順序是怎麼決定的呢?通過測試發現是 建模時候的頂點id順序決定的,那麼我們是否可以 預計算一些角度的排序數組,根據實時角度 進行切換呢?在很多情況下是可行的 比如 左右轉動的角色頭髮,甚至是遊戲內的頭髮 也能通過6-8-6 22個方向很精確的表達(赤道上8個方向,上下45度處 各6個方向)。以下是只預計算4個方向的效果。
默認半透明深度 各種角度錯誤
僅僅預計算4個方向的切換效果
代碼實現:
using System;using System.Collections;using System.Collections.Generic;using System.Diagnostics;using UnityEngine;
public class AlphaBakedOrder : MonoBehaviour{ class IndiceData : IComparable<IndiceData> { public int a; public int b; public int c; public float score;
public int CompareTo(IndiceData other){ return (int)((other.score - score) * 10000); } } public int[][] indicesDir; private int lastIndex; private int[] originIndices;
bool isReverse; Mesh mesh; Vector3 centerPt; void Awake(){ centerPt = transform.GetChild(0).localPosition; mesh = GetComponent<MeshFilter>().mesh; var indices = originIndices = mesh.GetIndices(0); var vertices = mesh.vertices; for (int i = 0; i < vertices.Length; i++) {
vertices[i] = transform.localToWorldMatrix.MultiplyPoint(vertices[i]);
}
indicesDir = new int[4][];
calculateDirIndices(out indicesDir[0], out indicesDir[2], indices, vertices, 0); calculateDirIndices(out indicesDir[1], out indicesDir[3], indices, vertices, 2);
}
private void calculateDirIndices(out int[] v1, out int[] v2, int[] indices, Vector3[] vertices, int dirCheck){
List<IndiceData> orderList = new List<IndiceData>(); for (int i = 0; i < indices.Length; i += 3) { IndiceData data = new IndiceData(); data.a = indices[i]; data.b = indices[i + 1]; data.c = indices[i + 2]; data.score = (vertices[data.a][dirCheck] + vertices[data.b][dirCheck] + vertices[data.c][dirCheck]);
orderList.Add(data); } orderList.Sort(); v1 = new int[indices.Length]; for (int i = 0; i < indices.Length; i += 3) { v1[i] = orderList[i / 3].a; v1[i + 1] = orderList[i / 3].b; v1[i + 2] = orderList[i / 3].c; } orderList.Reverse(); v2 = new int[indices.Length]; for (int i = 0; i < indices.Length; i += 3) { v2[i] = orderList[i / 3].a; v2[i + 1] = orderList[i / 3].b; v2[i + 2] = orderList[i / 3].c; }
} private void OnDisable(){ mesh.SetIndices(originIndices, MeshTopology.Triangles, 0); lastIndex = -1; }
void Update(){ if (Camera.main == null) return; var checkPos = Vector3.Normalize(Camera.main.transform.position - transform.localToWorldMatrix.MultiplyPoint3x4(centerPt)); var dotX = Vector3.Dot(transform.right, checkPos); var dotY = Vector3.Dot(transform.up, checkPos);
var index = 0; if (Mathf.Abs(dotY) < Mathf.Abs(dotX)) { index = dotX > 0 ? 2 : 0;
} else { index = dotY > 0 ? 1 : 3;
}
if (lastIndex != index) { mesh.SetIndices(indicesDir[index], MeshTopology.Triangles, 0); lastIndex = index; print(index); }
}}unity 半透明渲染技巧(3):深度剝離法
DepthPeeling !沒錯 目前實時渲染最先進的OIT 技術之一 其他的我就知道只有Per-PixelLinkedLists 了,前者的優點是1顯存可以更小 ,2有限次數時候 丟棄的層對畫質影響更小,比如5層半透明後面就當看不見沒人察覺 。缺點是需要多次渲染 總的速度不如後者快,但穩定些。
效果圖對比
alpha blend的 常見錯亂
6層剝離的效果
需求
前面寫了2種技巧 但都有限制 方案1 只能邊緣半透明,而且多人的邊緣半透明疊加時候深度依然錯誤。方案2 有時候需要預計算很多方向,且最高也就三角形級別的精確。做不到像素級別。所以 實現了這版 除了消耗點性能 通用性和精確度都很高
原理引擎默認是之渲染離相機最近的一層depth,和color。這種始終對無序半透明渲染是信息不足的,我們想 如果 每一處遮擋 都可以保留遮擋後面的最近的顏色 那麼我們自然就能 對這些顏色 一起混合。就像做ui 拿到5張從前到後的圖片 計算他們透明疊加就很簡單了。那麼怎麼獲得這些顏色呢?一個比較簡單的方案是這樣:首先常規渲染depth 離相機最近的一層,然後把這個depth 設置給shader 下次渲染採樣。下次渲染的時候 遇到比這個depth更接近或一樣近 相機的 fragment 就discard。這樣就渲染出 第二層接近相機的depth和 color,那麼再渲染一次 又會得到第三層 以此類推。
最後我們把得到的幾層 從後完全混合 常用的算法就是back.rgb(1-col.a)+col.rgb*col.a; 這樣就實現了
每一層的深度圖
優化這種簡單的實現方便理解原理,但是比較佔顯存。因為 從前往後渲染多次 但從後往前混合 所以需要同時保存這些顏色 就需要用rendertexutre array 保存好幾張。這方面優化的方案有2個 一個是 從後往前拍 同時混合。只要一張rendertexutre 不斷混合。一個是 還是從前往後拍 但也 只用一張rendertexutre 不斷混合。從前往後的混合 我根據基礎的物理光穿透計算 大致是這樣 col.rgb=col.rgb*(col.a)+back.rgb*(1-col.a);
col.a=1-(1-col.a)*(1-back.a); 不是完全準確但效果我測過很接近可以省5,6張屏幕大小RT 有時候很划算還有一種優化是一次性寫入同一個fragment不同深度的各種顏色 做個原子累加 不同次數的顏色 寫入到RWStructedBuffer 不同offset。我想到這裡時就被及時的告知 這可能就是 Per-PixelLinkedLists了
代碼講解與連結renderTexture 創建與作用
因為同時渲染出color和depth,這裡採用MRT渲染,後續會嘗試優化層 普通渲染+camera深度圖獲取
rts 為 MRT渲染目標 其中rts[0]存放深度 rts[1]存放顏色
finalClips 為存儲每個rts[1]的數組 最後對他每個圖進行半透明混合
rtTemp 是臨時從rts[0]拷貝出來 給下一次渲染的時候做深度比對的 但還不清楚為什麼 不能直接用rts[0] 我之前是猜測讀寫不能同時 所以這樣拷貝一次 沒想到被我猜中這樣就成功了
finalClipsMat = new Material(finalClipsShader); rts = new RenderTexture[2] { new RenderTexture(sourceCamera.pixelWidth, sourceCamera.pixelHeight, 0, RenderTextureFormat.RFloat), new RenderTexture(sourceCamera.pixelWidth, sourceCamera.pixelHeight, 0, RenderTextureFormat.Default) }; rts[0].Create(); rts[1].Create(); finalClips = new RenderTexture(sourceCamera.pixelWidth, sourceCamera.pixelHeight, 0, RenderTextureFormat.Default);
finalClips.dimension = TextureDimension.Tex2DArray; finalClips.volumeDepth = 6; finalClips.Create();
Shader.SetGlobalTexture("FinalClips", finalClips); rtTemp = new RenderTexture(sourceCamera.pixelWidth, sourceCamera.pixelHeight, 0, RenderTextureFormat.RFloat); rtTemp.Create();
Shader.SetGlobalTexture("DepthRendered", rtTemp);
多次渲染
for (int i = 0; i < depthMax; i++) { Graphics.Blit(rts[0], rtTemp); Shader.SetGlobalInt("DepthRenderedIndex", i); tempCamera.RenderWithShader(MRTShader, ""); Graphics.CopyTexture(rts[1], 0, 0, finalClips, i, 0); }
if (showFinal == false) { Graphics.Blit(rts[rt.GetHashCode()], destination); } else { Graphics.Blit(null, destination, finalClipsMat); }mrt 渲染shader
DepthRendered 是上一次渲染的深度 所以和他對比 比他靠近相機就是渲染過的 直接discard,因為DepthRendered是屏幕空間的 所以需要 用screenPos採樣,i.uv是模型uv不能直接用,最後輸出2個目標 深度和顏色
fout frag(v2f i) { float depth = i.pos.z / i.pos.w; fixed shadow = SHADOW_ATTENUATION(i); half4 col=tex2D(_MainTex,i.uv)*_Color*shadow; col.rgb *=i.color; clip(col.a-0.001); float renderdDepth=tex2D(DepthRendered,i.screenPos.xy/i.screenPos.w).r; if(DepthRenderedIndex>0&&depth>=renderdDepth-0.000001) discard; fout o; o.rt0=depth; o.rt1=col; return o; }FinalClip 最終混合shader
fixed4 col =0; fixed4 top=0; for(int k=0;k<DepthRenderedIndex+1;k++){ fixed4 front= UNITY_SAMPLE_TEX2DARRAY(FinalClips, float3(i.uv, DepthRenderedIndex-k)); col.rgb=col.rgb*(1-front.a)+front.rgb*front.a; col.a=1-(1-col.a)*(1-front.a); top=col; } col.a=saturate(col.a); col.rgb= col.rgb+top.rgb*(1-col.a); return col;代碼連結
github.com/jackie2009/depthPeeling
題外話做這個系列有很巧的事情發生,就是一開始我想了一個很蠢的辦法,就是根據不同的距離 做不同範圍的多次渲染 也得到了 多次切片顏色 我叫他clipColor,比如一個頭髮看成0.1米厚的立方體 第一次渲染頭髮的0到0.01處 第二次0.01-0.02處。。。這樣切10片渲染。這樣雖然也能實現正確排序但非常低效。然後我就想能不能根據深度圖來推進渲染位置,於是跑到樂樂女神TA群問下這樣做可行性 結果有大佬秒回我 depthpeeling。一看思路幾乎絲毫不差。但他成熟專業的多 還 提出 雙向渲染 減少一半次數等。做完想優化的時候 想出方式一討論又被告知 就是 Per-PixelLinkedLists。這種感覺很久沒有了,上一次印象深刻還是 2003年自己想出的 尋路 和A*驚人相近。說了這麼多巧合 我想表達 有一種人就是屬於:書看得太少 但腦子想得太多——
參考閱讀:
透明渲染:渲染模型時控制它的透明通道(Alpha Channel)
對不透明(opaque)物體,由於深度緩衝(depth buffer,z-buffer),不考慮渲染順序也能得到正確的排序效果。
深度緩衝:渲染一個片元時,把它的深度值(視錐空間中距離攝像機的距離,範圍0-1)和深度緩衝中的值進行比較(如開啟深度測試ZTest),通過測試(深度值距離攝像機),則這個片元應該覆蓋掉此時顏色緩衝中的像素值,並更新它的深度值到深度緩衝(如果開啟了深度寫入)。(不開的話,深度緩衝中的值就是只讀的)。
關閉深度寫入原因:否則測試成功就會寫入顏色緩衝區,不進行顏色混合。
渲染順序:A(黃色)是半透明物體,B(紫色)是不透明物體
透明與不透明物體之間:不透明物體渲染完之後再渲染半透明物體。
半透明物體之間:從後往前
渲染引擎一般都會先對物體進行排序,再渲染。
常用方法:
1、先渲染所有不透明物體,開啟它們的深度測試和深度寫入。
2、把半透明物體按它們距離攝像機的遠近進行排序,按照從後往前的順序渲染,開啟它們的深度測試,關閉深度寫入。
但循環重疊的半透明物體會無法得到正確的半透明效果。
這裡的問題是:如何排序?
紅色點分別標明了網格上距離攝像機最近的點、最遠的點以及網格中點,每個點的深度值可能都不同,選擇哪個深度值在某些情況下半透明物體之間一定會出現錯誤的遮擋問題,解決方法:分割網格。
為減少錯誤排序的情況,儘可能讓模型是凸面體,將複雜的模型拆分成可以獨立排序的多個子模型。就算排序錯誤結果也不會非常糟糕。如果不想分割網格,可以試著讓透明通道更加柔和,使穿插看起來並不是那麼明顯。也可以使用開啟了深度寫入的半透明效果來近似模擬物體的半透明。
Unity中的渲染排序(Unity已經提供了很好地渲染順序解決方案,我們拿來用就行了~)
Unity提供了渲染隊列(render queue)解決渲染順序的問題。SubShader的Queue標籤決定模型歸於哪個渲染隊列。
Unity內部用一系列整數索引來表示每個渲染隊列,越小表示越早被渲染。
Unity提前定義了5個渲染隊列(也可以自定義)。
Unity內部用一系列整數索引來表示每個渲染隊列,越小表示越早被渲染
透明度測試代碼
透明度混合代碼:自定義渲染隊列索引,相當於2000+1。在所有的非透明物體之後渲染。
來源知乎專欄:遊戲技術輪子