GPU 光線追蹤是當今的熱門話題,所以讓我們來談談它!今天我們將光線追蹤一個單個球體。
使用片段著色器。
是的,我知道。并不特別花哨。你可以在?Shadertoy 上搜索并獲得數百個示例(https://www.shadertoy.com/results?query=sphere)。甚至已經有一些很棒的教程教你如何做?球體Imposter
(https://paroj.github.io/gltut/Illumination/Tutorial 13.html),
這就是我們要做的。那么我為什么要寫另一篇關于它的文章呢?它甚至不是正確類型的 GPU 光線追蹤!
好吧,因為光線追蹤部分并不是我真正要關注的部分。這篇文章更多的是關于如何在 Unity 中將不透明的光線追蹤或光線行進物體注入到光柵化場景中。但也介紹了一些處理渲染球體Imposter的額外技巧,這些技巧并不總是顯而易見或被我見過的其他教程所涵蓋。在這篇文章的最后,我們將得到一個緊湊的四邊形上的球體Imposter,它支持多個燈光、陰影投射、陰影接收和正交相機,用于內置的前向渲染器,幾乎完美地模擬了一個高多邊形網格。無需額外的 C# 腳本。
我的第一個球體Imposter
如引言中所述,這是一個已經被廣泛探索的領域。繪制球體的準確高效的數學方法已經為人所知。所以我只是要從 Inigo Quilez 的代碼中竊取適用的函數,來創建一個基本的光線追蹤球體著色器,我們可以將其貼到立方體網格上。
https://www.iquilezles.org/www/articles/intersectors/intersectors.htm
Inigo 的示例都是用 GLSL 編寫的。所以我們需要稍微修改一下代碼才能讓它適用于 HLSL。幸運的是,對于這個函數來說,這實際上只需要將?vec
?替換成?float
。
float?sphIntersect(?float3?ro,?float3?rd,?float4?sph?)
{float3?oc?=?ro?-?sph.xyz;float?b?=?dot(?oc,?rd?);float?c?=?dot(?oc,?oc?)?-?sph.w*sph.w;float?h?=?b*b?-?c;if(?h<0.0?)?return?-1.0;h?=?sqrt(?h?);return?-b?-?h;
}
該函數接受 3 個參數:ro
(光線起點)、rd
(歸一化的光線方向)和?sph
(球體位置 xyz 和半徑 w)。它返回光線從起點到球體表面的長度,或者在未命中時返回?-1.0
。簡單明了。所以我們只需要這三個向量,我們就可以得到一個漂亮的球體。
光線起點可能是最容易獲得的點。對于 Unity 著色器來說,它將是相機位置。方便地傳遞給全局著色器?_WorldSpaceCameraPos
?中的每個著色器。對于正交相機來說,它稍微復雜一些,但幸運的是,我們不必擔心。
不祥的預兆
對于球體位置,我們可以使用我們正在應用著色器的物體的世界空間位置。這可以通過?unity_ObjectToWorld._m03_m13_m23
?從物體的變換矩陣中輕松提取。我們可以將半徑設置為某個任意值。為了沒有特別的理由,讓我們選擇?0.5
。
最后是光線方向。這只是從相機到我們代理網格的世界位置的方向。通過在頂點著色器中計算它并將向量傳遞給片段著色器,我們可以很容易地獲得它。
float3?worldPos?=?mul(unity_ObjectToWorld,?v.vertex);
float3?rayDir?=?_WorldSpaceCameraPos.xyz?-?worldPos;
請注意,在頂點著色器中對其進行歸一化非常重要。你需要在片段著色器中執行此操作,否則插值的值將無法正常工作。我們正在插值的值是表面位置,而不是實際的光線方向。
但是經過所有這些,我們得到了光線追蹤球體所需的三個值。
現在我說上面的函數返回光線長度。所以要獲得球體表面的實際世界空間位置,你將歸一化的光線乘以光線長度,然后加上光線起點。你甚至可以通過從球體位置減去表面位置并進行歸一化來獲得世界法線。我們將光線長度傳遞給?clip()
?函數,以隱藏球體外部的任何東西,因為該函數在未命中時返回?-1.0
。
深度查找器
球體Imposter的最后一個要點是 z 深度。如果我們希望我們的球體與世界正確地相交,我們需要從片段著色器中輸出球體的深度。否則,我們將被迫使用我們用來渲染的網格的深度。這實際上比聽起來容易得多。由于我們已經在片段著色器中計算了世界位置,我們可以應用我們在頂點著色器中使用的相同視圖和投影矩陣來獲得 z 深度。Unity 甚至包含一個方便的?UnityWorldToClipPos()
?函數,使它變得更加容易。然后,它需要一個使用?SV_Depth
?的輸出參數,其中包含剪切空間位置的 z 除以其 w。
將所有這些與一些基本的光照結合起來,你就會得到類似這樣的東西:
?
它看起來像一個球體,但實際上是一個立方體。
讓所有男人都為之驚嘆的一個非常圓的立方體
Shader?"Basic?Sphere?Impostor"
{Properties{}SubShader{Tags?{?"RenderType"="AlphaTest"?"DisableBatching"="True"?}LOD?100Pass{Tags?{?"LightMode"?=?"ForwardBase"?}CGPROGRAM#pragma?vertex?vert#pragma?fragment?frag#include?"UnityCG.cginc"struct?appdata{float4?vertex?:?POSITION;};struct?v2f{float4?pos?:?SV_POSITION;float3?rayDir?:?TEXCOORD0;float3?rayOrigin?:?TEXCOORD1;};v2f?vert?(appdata?v){v2f?o;//?get?world?position?of?vertex//?using?float4(v.vertex.xyz,?1.0)?instead?of?v.vertex?to?match?Unity's?codefloat3?worldPos?=?mul(unity_ObjectToWorld,?float4(v.vertex.xyz,?1.0));//?calculate?and?world?space?ray?direction?and?origin?for?interpolationo.rayDir?=?worldPos?-?_WorldSpaceCameraPos.xyz;o.rayOrigin?=?_WorldSpaceCameraPos.xyz;o.pos?=?UnityWorldToClipPos(worldPos);return?o;}//?https://www.iquilezles.org/www/articles/spherefunctions/spherefunctions.htmfloat?sphIntersect(?float3?ro,?float3?rd,?float4?sph?){float3?oc?=?ro?-?sph.xyz;float?b?=?dot(?oc,?rd?);float?c?=?dot(?oc,?oc?)?-?sph.w*sph.w;float?h?=?b*b?-?c;if(?h<0.0?)?return?-1.0;h?=?sqrt(?h?);return?-b?-?h;}half3?_LightColor0;half4?frag?(v2f?i,?out?float?outDepth?:?SV_Depth)?:?SV_Target{//?ray?originfloat3?rayOrigin?=?i.rayOrigin;//?normalize?ray?vectorfloat3?rayDir?=?normalize(i.rayDir);//?sphere?positionfloat3?spherePos?=?unity_ObjectToWorld._m03_m13_m23;//?ray?box?intersectionfloat?rayHit?=?sphIntersect(rayOrigin,?rayDir,?float4(spherePos,?0.5));//?above?function?returns?-1?if?there's?no?intersectionclip(rayHit);//?calculate?world?space?position?from?ray,?front?hit?ray?length,?and?ray?originfloat3?worldPos?=?rayDir?*?rayHit?+?rayOrigin;//?world?space?surface?normalfloat3?worldNormal?=?normalize(worldPos?-?spherePos);//?basic?lightinghalf3?worldLightDir?=?_WorldSpaceLightPos0.xyz;half?ndotl?=?saturate(dot(worldNormal,?worldLightDir));half3?lighting?=?_LightColor0?*?ndotl;//?ambient?lightinghalf3?ambient?=?ShadeSH9(float4(worldNormal,?1));lighting?+=?ambient;//?output?modified?depthfloat4?clipPos?=?UnityWorldToClipPos(worldPos);outDepth?=?clipPos.z?/?clipPos.w;return?half4(lighting,?1.0);}ENDCG}}
}
紋理化球體
好吧,這并不太令人興奮。我們應該在上面放一個紋理。為此,我們需要 UV,幸運的是,對于球體來說,這些 UV 非常容易獲得。
等距矩形 UV
讓我們在上面貼一個等距矩形紋理。為此,我們只需要將法線方向輸入到?atan2()
?和?acos()
?中,我們就會得到類似這樣的東西:
float2?uv?=?float2(//?atan?返回?-pi?到?pi?之間的值//?所以我們除以?pi?*?2?來得到?-0.5?到?0.5atan2(normal.z,?normal.x)?/?(UNITY_PI?*?2.0),//?acos?在頂部返回?0.0,在底部返回?pi//?所以我們將?y?翻轉以與?Unity?的?OpenGL?風格對齊//?紋理?UV,所以?0.0?在底部acos(-normal.y)?/?UNITY_PI
);fixed4?col?=?tex2D(_MainTex,?uv);
?
地球,最后的疆域。
看看,我們得到一個完美的……等等。這是什么!?
?
那是格林威治子午線嗎?
這是一個 UV 縫!我們怎么會出現 UV 縫呢?好吧,這取決于 GPU 如何為 mip 貼圖計算 mip 層級。
縫合
GPU 通過所謂的屏幕空間偏導數來計算 mip 層級。粗略地說,這是值從一個像素到它旁邊的一個像素(向上或向下)的變化量。GPU 可以為每組 2x2 像素計算此值,因此 mip 層級由這些 2x2“像素四邊形”中 UV 的變化量決定。當我們在這里計算 UV 時,atan2()
?突然在兩個像素之間從大約?0.5
?跳到大約?-0.5
。這使得 GPU 認為整個紋理在這兩個像素之間顯示。因此,它會使用它擁有的絕對最小的 mip 貼圖來響應。
那么我們如何解決這個問題呢?當然,通過禁用 mip 貼圖!
不不不!?我們絕對不會這樣做。?但這是你通常會找到的解決大多數 mip 貼圖相關問題的方案。相反,Marco Tarini 提供了一個很好的解決方案。
http://vcg.isti.cnr.it/~tarini/no-seams/
這個想法是使用兩個 UV 集,它們在不同的位置有縫合。對于我們的特定情況,由?atan2()
?計算的經度 UV 已經是?-0.5
?到?0.5
?的范圍,所以我們只需要一個?frac()
?來將它們轉換為?0.0
?到?1.0
?的范圍。然后使用相同的偏導數來選擇變化最小的 UV 集。神奇的函數?fwidth()
?給出了值在任何屏幕空間方向上的變化量。
//?-0.5?到?0.5?的范圍
float?phi?=?atan2(worldNormal.z,?worldNormal.x)?/?(UNITY_PI?*?2.0);
//?0.0?到?1.0?的范圍
float?phi_frac?=?frac(phi);float2?uv?=?float2(//?使用一個小偏差來優先考慮第一個“UV?集”fwidth(phi)?<?fwidth(phi_frac)?-?0.001???phi?:?phi_frac,acos(-worldNormal.y)?/?UNITY_PI
);
現在我們沒有縫合了!
我保證它沒有隱藏在另一邊
** 后記:我注意到這種技術可能只在使用 Direct3D、集成英特爾 GPU 或(某些?)Android OpenGLES 設備時才能正常工作。在桌面設備上使用 OpenGL 時,*?fwidth()
?函數可能使用比 GPU 用于確定 mip 層級的精度更高的導數,這意味著縫合仍然可見。Metal 保證始終以更高的精度運行。Vulkan 可以通過使用粗導數函數來強制以較低的精度運行,但截至撰寫本文時,Unity 似乎沒有正確地轉譯粗導數或精導數。我寫了一篇后續文章,其中介紹了一些替代解決方案:
https://bgolus.medium.com/distinctive-derivative-differences-cce38d36797b
或者,你可以直接使用立方體貼圖。Unity 可以為你將導入的等距矩形紋理轉換為立方體貼圖。但這意味著你將失去各向異性過濾。立方體貼圖紋理采樣的 UVW 本質上只是球體的法線。不過,你確實需要翻轉 x 軸或 z 軸,因為立方體貼圖被假定為從球體的“內部”進行觀察,而在這里我們希望它映射到外部。
粗糙邊緣(又名導數再次出現)
此時,如果我們將現有的光線追蹤球體著色器與使用相同等距矩形 UV 的實際高多邊形網格球體進行比較,你可能會注意到一些奇怪的事情。看起來光線追蹤球體周圍有一個輪廓,而網格沒有。一個非常鋸齒的輪廓。
?
Imposter的粗糙“輪廓”。
原因是我們討厭的導數再次出現了。我們錯過了另一個 UV 縫!在網格上,導數是針對每個像素四邊形、每個三角形計算的。事實上,如果一個三角形只接觸到一個 2x2 像素四邊形中的一個像素,GPU 仍然會為所有 4 個像素運行片段著色器!這樣做的好處是,它可以準確地計算出合理的導數,從而防止在真實網格上出現此問題。但我們在球體外部沒有一個好的 UV,該函數在未命中時只返回一個常數?-1.0
,因此我們在球體外部有錯誤的 UV。如果在著色器中注釋掉?clip()
?和?outDepth
?行,我們可以清楚地看到這一點。
?
隱藏的 UV 縫
我們想要的是讓 UV 接近球體可見邊緣的值,或者可能剛剛超過邊緣。這令人驚訝地難以計算。但我們可以通過找到光線到球體中心的最近點來獲得一個相當接近的值。在球體邊緣,這是 100% 準確的,但當離球體越來越遠時,它會開始向相機方向彎曲。但這很便宜,足以消除這個問題,并且與完全正確的修復幾乎沒有區別。
?
更棒的是,當球體相交函數返回?-1.0
?時,我們可以通過用一個?dot()
?替換光線長度來應用此修復。兩個向量的點積的一個超級能力是,如果至少一個向量是歸一化的,則輸出是另一個向量沿歸一化向量方向的幅度。這對于獲取某個方向上的距離非常有用,例如相機沿視圖光線距離球體樞軸的距離。
//?相同的球體相交函數
float?rayHit?=?sphIntersect(rayOrigin,?rayDir,?float4(0,0,0,0.5));
//?如果是?-1.0,則剪切以在未命中時隱藏球體
clip(rayHit);
//?點積獲取最靠近球體的點處的光線長度
rayHit?=?rayHit?<?0.0???dot(rayDir,?spherePos?-?rayOrigin)?:?rayHit;
?
?
?
不再有縫合。
物體縮放和旋轉
所以一切都進展順利,但如果我們想做一個更大的球體或旋轉它怎么辦?我們可以移動網格位置,球體會隨之移動,但其他所有東西都被忽略了。
我們可以手動更改球體半徑,但隨后你必須手動保持你正在使用的網格同步。所以,從物體變換本身提取縮放比例會更容易。我們可以應用一個任意的旋轉矩陣,但同樣,如果我們能直接使用物體變換,那就更容易了。
或者,我們可以做一些更簡單的事情,在物體空間中進行光線追蹤!這帶來了一些其他的好處,我們將在后面介紹。但在那之前,我們想要在著色器代碼中添加幾行。首先,我們想要使用?unity_WorldToObject
?矩陣將光線起點和光線方向在頂點著色器中轉換為物體空間。在片段著色器中,我們不再需要從變換中獲取世界空間物體位置,因為球體現在可以位于物體的原點。
//?頂點著色器
float3?worldSpaceRayDir?=?worldPos?-?_WorldSpaceCameraPos.xyz;
//?只想旋轉和縮放?dir?向量,所以?w?=?0
o.rayDir?=?mul(unity_WorldToObject,?float4(worldSpaceRayDir,?0.0));
//?需要對起點向量應用完整的變換
o.rayOrigin?=?mul(unity_WorldToObject,?float4(_WorldSpaceCameraPos.xyz,?1.0));//?片段著色器
float3?spherePos?=?float3(0,0,0);
僅通過添加上面的代碼到我們的著色器,你就可以旋轉和縮放游戲物體,球體也會按預期進行縮放和旋轉。它甚至支持非均勻縮放!請記住,著色器中的所有這些“世界空間”位置現在都在物體空間中。所以我們需要將法線和球體表面位置轉換為世界空間。只需確保使用物體空間法線作為 UV。
//?現在獲取物體空間表面位置,而不是世界空間
float3?objectSpacePos?=?rayDir?*?rayHit?+?rayOrigin;//?仍然需要在物體空間中對其進行歸一化以用于?UV
float3?objectSpaceNormal?=?normalize(objectSpacePos);float3?worldNormal?=?UnityObjectToWorldNormal(objectSpaceNormal);
float3?worldPos?=?mul(unity_ObjectToWorld,?float4(objectSpacePos,?1.0));
?
大、小和可怕的三明治地球。
其他優勢包括更好的整體精度,因為對所有內容使用世界空間會在遠離原點時導致一些精度問題。在使用物體空間時,這些問題至少可以部分避免。這也意味著我們可以刪除幾個地方對?spherePos
?的使用,因為它都是零,從而簡化代碼。
使用四邊形
到目前為止,我們一直在使用立方體網格。在某些情況下,使用立方體確實有一些好處,但我承諾在本文的標題中使用四邊形。而且,實際上沒有充分的理由為一個球體使用整個立方體。在側面有很多浪費的空間,我們在那里支付了渲染球體的成本,而我們知道它不會在那里。尤其是默認的 Unity 立方體,它有 24 個頂點!為什么還要浪費計算額外的 20 個頂點?
公告牌著色器
有很多公告牌著色器的示例。它們的基本原理是忽略物體的變換的旋轉(和縮放!),而是將網格對齊到某個方向以面向相機。
面向視圖的公告牌
這可能是最常見的版本。這是通過將樞軸位置轉換為視圖空間,并將頂點偏移量添加到視圖空間位置來實現的。這樣做相對便宜。請記住更新光線方向以匹配。
//?從變換矩陣中獲取物體的世界空間樞軸
float3?worldSpacePivot?=?unity_ObjectToWorld._m03_m13_m23;//?轉換為視圖空間
float3?viewSpacePivot?=?mul(UNITY_MATRIX_V,?float4(worldSpacePivot,?1.0));//?物體空間頂點位置?+?視圖樞軸?=?公告牌四邊形
float3?viewSpacePos?=?v.vertex.xyz?+?viewSpacePivot;//?從視圖空間位置計算物體空間光線?dir
o.rayDir?=?mul(unity_WorldToObject,mul(UNITY_MATRIX_I_V,?float4(viewSpacePos,?0.0))
);//?應用投影矩陣以獲取剪切空間位置
o.pos?=?mul(UNITY_MATRIX_P,?float4(viewSpacePos,?1.0));
但是,如果我們只是將上面的代碼添加到我們的著色器中,球體就會出現一些問題。它在邊緣被剪切,尤其是在球體位于側面或靠近相機時。
?
想得太超出了范圍。
這是因為四邊形是一個平面,而球體不是。球體有一定的深度。由于透視,球體的體積將覆蓋比四邊形更多的屏幕!
藝術家對犯罪現場的再現
你可能會使用的解決方案是將公告牌按某個任意量進行縮放。但這并不能完全解決問題,因為你必須將四邊形放大很多。尤其是在你靠近球體或具有非常寬的視場時。這在一定程度上違背了使用四邊形而不是立方體的初衷。事實上,與立方體相比,即使是相對較小的縮放比例增加,現在也有更多像素渲染了空的空間。
?
面向相機的公告牌
幸運的是,我們可以做得更好。一個部分的解決方案是使用面向相機的公告牌,而不是面向視圖的公告牌,并將四邊形稍微拉向相機。面向視圖的公告牌和面向相機的公告牌之間的區別在于,面向視圖的公告牌與視圖所面向的方向對齊。面向相機的公告牌面向相機的位置。區別可能很細微,代碼也稍微復雜一些。
我們不再在視圖空間中執行操作,而是需要構建一個旋轉矩陣,將四邊形旋轉到面向相機。這聽起來比實際操作更可怕。你只需要獲取從物體位置指向相機的向量、前進向量,并使用叉積來獲取向上向量和向右向量。將這三個向量放在一起,你就得到了一個旋轉矩陣。
float3?worldSpacePivot?=?unity_ObjectToWorld._m03_m13_m23;//?樞軸和相機之間的偏移量
float3?worldSpacePivotToCamera?=?_WorldSpaceCameraPos.xyz?-?worldSpacePivot;//?相機向上向量
//?用作一個相當任意的向上方向起點
float3?up?=?UNITY_MATRIX_I_V._m01_m11_m2;//?前進向量是歸一化的偏移量
//?這是從樞軸到相機的方向
float3?forward?=?normalize(worldSpacePivotToCamera);//?叉積獲取一個垂直于輸入向量的向量
float3?right?=?normalize(cross(forward,?up));//?另一個叉積確保向上向量垂直于兩者
up?=?cross(right,?forward);//?構建旋轉矩陣
float3x3?rotMat?=?float3x3(right,?up,?forward);//?上面的旋轉矩陣是轉置的,這意味著組件是
//?順序錯誤,但我們可以通過交換
//?矩陣和向量在?mul()?中的順序來解決
float3?worldPos?=?mul(v.vertex.xyz,?rotMat)?+?worldSpacePivot;//?光線方向
float3?worldRayDir?=?worldPos?-?_WorldSpaceCameraPos.xyz;
o.rayDir?=?mul(unity_WorldToObject,?float4(worldRayDir,?0.0));//?剪切空間位置輸出
o.pos?=?UnityWorldToClipPos(worldPos);
?
這更好,但仍然不好。球體仍然剪切了四邊形的邊緣。實際上,現在是所有四個邊緣。至少它是居中的。好吧,我們忘記將四邊形移向相機了!從技術上講,我們也可以按任意量縮放四邊形,但讓我們回到這一點。
float3?worldPos?=?mul(float3(v.vertex.xy,?0.3),?rotMat)?+?worldSpacePivot;
我們忽略了四邊形的 z,并添加了一個小的(任意的)偏移量以將其拉向相機。與任意縮放相比,這種選擇的好處是,當距離較遠時,它應該更緊密地限制在球體的邊界內,并且當距離較近時,由于透視變化而進行縮放,就像球體本身一樣。只有當非常靠近時,它才會開始覆蓋比需要更多的屏幕空間。我在上面的示例中選擇了?0.3
,因為它是在靠近時不會覆蓋太多屏幕空間,同時仍然覆蓋所有可見球體,直到你非常非常靠近。
?
你知道,你可能可以用一些數學方法來計算出在給定距離下拉動或縮放四邊形的確切值……
完美的透視公告牌縮放
等等!我們可以用一些數學方法來計算出這個值!我們可以計算出相機到樞軸向量和相機到球體可見邊緣之間的角度。事實上,它始終是一個直角三角形,直角位于球體的表面!還記得你老朋友 SOHCAHTOA 嗎?我們知道相機到樞軸的距離,那是斜邊。我們也知道球體的半徑。由此,我們可以計算出從將該角度投影到四邊形的平面所形成的直角三角形的底邊。有了它,我們可以縮放四邊形,而不是修改?v.vertex.z
。
//?獲取直角三角形的正弦值,斜邊是?//?球體樞軸距離,對邊使用球體半徑
float?sinAngle?=?0.5?/?length(viewOffset);//?轉換為余弦
float?cosAngle?=?sqrt(1.0?-?sinAngle?*?sinAngle);//?轉換為正切
float?tanAngle?=?sinAngle?/?cosAngle;//?上面的兩行等效于此,但速度更快
//?tanAngle?=?tan(asin(sinAngle));//?獲取直角三角形對邊,直角位于球體樞軸處,乘以?2?以獲取四邊形大小
float?quadScale?=?tanAngle?*?length(viewOffset)?*?2.0;//?按計算的大小縮放四邊形
float3?worldPos?=?mul(float3(v.vertex.xy,?0.0)?*?quadScale,?rotMat)?+?worldSpacePivot;
?
考慮物體縮放
在這篇文章的開頭,我們將所有內容轉換為使用物體空間,這樣我們就可以輕松地支持旋轉和縮放。我們仍然支持旋轉,因為四邊形的朝向實際上并不重要。但四邊形不會像立方體那樣隨著物體的變換進行縮放。解決這個問題最簡單的方法是從變換矩陣的軸中提取縮放比例,并將我們使用的半徑乘以最大縮放比例。
//?獲取物體縮放比例
float3?scale?=?float3(length(unity_ObjectToWorld._m00_m10_m20),length(unity_ObjectToWorld._m01_m11_m21),length(unity_ObjectToWorld._m02_m12_m22)
);
float?maxScale?=?max(abs(scale.x),?max(abs(scale.y),?abs(scale.z)));//?將球體半徑乘以最大縮放比例
float?maxRadius?=?maxScale?*?0.5;//?使用新的半徑更新我們的正弦計算
float?sinAngle?=?maxRadius?/?length(viewOffset);//?執行其余的縮放代碼
現在你可以均勻地縮放游戲物體,球體仍然會完美地限制在四邊形內。
橢球體邊界?
也應該可以計算出橢球體或非均勻縮放球體的精確邊界。不幸的是,這開始變得有點困難了。所以我現在不會花精力去解決這個問題。我將把它留作“讀者的練習”。(也就是說,我不知道怎么做。)
視錐體剔除
使用四邊形的另一個問題是 Unity 的視錐體剔除。它不知道四邊形在著色器中被旋轉了,因此,如果游戲物體被旋轉,使其以邊緣朝向觀察者,它可能會被視錐體剔除,而球體仍然可見。解決這個問題的方法是使用一個自定義的四邊形網格,其邊界已通過 C# 代碼手動修改為一個盒子。或者,你可以使用一個四邊形網格,其中一個頂點向前推了 0.5,另一個頂點向后推了 0.5,位于 z 軸上。我們已經在著色器中通過用?0.0
?替換?v.vertex.z
?來展平網格。
陰影投射
所以現在我們得到了一個漂亮渲染的球體,它位于一個四邊形上,可以被照亮、紋理化,并且可以移動、縮放和旋轉。所以讓我們讓它投射陰影!為此,我們需要在著色器中創建一個陰影投射器通道。幸運的是,相同的頂點著色器可以在這兩個通道中重復使用,因為它只創建了一個四邊形,并將光線起點和方向傳遞下去。當然,這些對于陰影來說與相機完全相同,對吧?然后,片段著色器實際上只需要輸出深度,這樣你就可以刪除所有討厭的 UV 和光照代碼。
哦。
光線起點和方向需要來自光源,而不是相機。我們用來表示光線起點的值始終是當前相機位置,而不是光源。好消息是,這并不難修復。我們可以用?UNITY_MATRIX_I_V._m03_m13_m23
?替換任何對?_WorldSpaceCameraPos
?的使用,它從逆視圖矩陣中獲取當前視圖的世界位置。現在,只要陰影是用透視投影渲染的,它就應該可以正常工作!
哦。哦,不。
方向陰影使用正交投影。
正交痛苦
透視投影和光線追蹤的優點是,光線起點位于相機的位置。這很容易獲得,即使對于任意視圖也是如此,如上所示。對于正交投影,光線方向是前進視圖向量。這很容易從逆視圖矩陣中再次獲得。
//?視圖空間中的前進方向是?-z,所以我們想要負向量
float3?worldSpaceViewForward?=?-UNITY_MATRIX_I_V._m02_m12_m22;
但是我們如何獲得正交光線起點呢?如果你嘗試在線搜索,你可能會看到很多示例使用 C# 腳本來獲取逆投影矩陣。或者濫用當前的?unity_OrthoParams
,它包含有關正交投影的寬度和高度的信息。然后,你可以使用剪切空間位置來重建光線起源的近視平面位置。這些方法的問題在于,它們都獲取的是相機的正交設置,而不是當前光源的設置。所以我們必須在著色器中計算逆矩陣!
float4x4?inverse(float4x4?m)?{float?n11?=?m[0][0],?n12?=?m[1][0],?n13?=?m[2][0],?n14?=?m[3][0];float?n21?=?m[0][1],?n22?=?m[1][1],?n23?=?m[2][1],?n24?=?m[3][1];float?n31?=?m[0][2],?n32?=?m[1][2],?n33?=?m[2][2],?n34?=?m[3][2];float?n41?=?m[0][3],?n42?=?m[1][3],?n43?=?m[2][3],?n44?=?m[3][3];??float?t11?=?n23?*?n34?*?n42?-?n24?*?n33?*?n42?+?n24?*?n32?*?n43?-?n22?*?n34?*?n43?-?n23?*?n32?*?n44?+?n22?*?n33?*?n44;//?...?等等,還有多少行?
好吧,我們不要這樣做。這些只是超過 30 行函數的前幾行,而且越來越長,越來越復雜。一定有更好的方法。
幾乎是視平面
事實證明,你不需要任何這些。我們實際上并不需要光線起點位于近平面。光線起點實際上只需要是沿著前進視圖向量拉回的網格位置。只要足夠遠,以確保它沒有從球體的體積內部開始。至少假設相機本身還沒有位于球體內部。并且相機位置處的“近平面”而不是實際的近平面完全符合這個要求。
我們已經在頂點著色器中知道了頂點的世界位置。所以我們可以將世界位置轉換為視圖空間。將?viewSpacePos.z
?設置為零,然后轉換回世界空間。這將產生一個可用于正交投影的光線起點!
//?將世界空間頂點位置轉換為視圖空間
float4?viewSpacePos?=?mul(UNITY_MATRIX_V,?float4(worldPos,?1.0));//?將視圖空間位置展平到相機平面上
viewSpacePos.z?=?0.0;//?轉換回世界空間
float4?worldRayOrigin?=?mul(UNITY_MATRIX_I_V,?viewSpacePos);//?正交光線?dir
float3?worldRayDir?=?worldSpaceViewForward;//?以及到物體空間
o.rayDir?=?mul(unity_WorldToObject,?float4(worldRayDir,?0.0));
o.rayOrigin?=?mul(unity_WorldToObject,?worldRayOrigin);
實際上,我們甚至不需要做所有這些。還記得上面提到的?dot()
?的超級能力嗎?我們只需要相機到頂點位置向量和歸一化的前進視圖向量。我們已經有了相機到頂點位置向量,那是原始的透視世界空間光線方向。我們知道前進視圖向量,可以通過從上面提到的矩陣中提取它來獲得。方便的是,此向量已經歸一化了!所以我們可以刪除上面的代碼中的兩個矩陣乘法,并改為執行以下操作:
float3?worldSpaceViewPos?=?UNITY_MATRIX_I_V._m03_m13_m23;
float3?worldSpaceViewForward?=?-UNITY_MATRIX_I_V._m02_m12_m22;//?原始的透視光線?dir
float3?worldCameraToPos?=?worldPos?-?worldSpaceViewPos;//?正交光線?dir
float3?worldRayDir?=?worldSpaceViewForward?*?-dot(worldCameraToPos,?worldSpaceViewForward);//?正交光線起點
float3?worldRayOrigin?=?worldPos?-?worldRayDir;o.rayDir?=?mul(unity_WorldToObject,?float4(worldRayDir,?0.0));
o.rayOrigin?=?mul(unity_WorldToObject,?float4(worldRayOrigin,?1.0));
** 這里有一個小問題。這對于傾斜投影(即剪切的正交投影)不起作用。為此,你確實需要逆投影矩陣。但是剪切的透視投影是可以的!*
面向光源的公告牌
還記得我們是如何做面向相機的公告牌的嗎?以及用于縮放四邊形以考慮透視的那些花哨的數學方法嗎?對于正交投影,我們不需要任何這些。只需要執行面向視圖的公告牌,并將四邊形按物體的變換的最大縮放比例進行縮放。但是也許我們不要刪除所有這些代碼。我們可以照常使用現有的旋轉矩陣構建,只是將?forward
?向量更改為負的?worldSpaceViewForward
?向量,而不是?worldSpacePivotToCamera
?向量。
透視點
事實上,現在可能是討論聚光燈和點光源如何使用透視投影的好時機。如果我們想要支持方向光、聚光燈和點光源陰影,我們需要在同一個著色器中同時支持透視和正交投影。Unity 還使用此通道來渲染相機深度紋理。這意味著我們需要檢測當前投影矩陣是否是正交的,并在兩種路徑之間進行選擇。
好吧,我們可以通過檢查投影矩陣的特定組件來找出我們正在使用哪種類型的投影矩陣。如果投影矩陣的最后一個組件是?0.0
,則它是透視投影矩陣,如果它是?1.0
,則它是正交投影矩陣。
bool?isOrtho?=?UNITY_MATRIX_P._m33?==?1.0;//?公告牌代碼
float3?forward?=?isOrtho???-worldSpaceViewForward?:?normalize(worldSpacePivotToCamera);
//?執行其余的公告牌代碼//?四邊形縮放代碼
float?quadScale?=?maxScale;
if?(!isOrtho)
{//?執行完美的縮放代碼
}//?光線方向和起點代碼
float3?worldRayOrigin?=?worldSpaceViewPos;
float3?worldRayDir?=?worldPos?-?worldSpaceRayOrigin;
if?(isOrtho)
{worldRayDir?=?worldSpaceViewForward?*?-dot(worldRayDir,?worldSpaceViewForward);worldRayOrigin?=?worldPos?-?worldRayDir;
}o.rayDir?=?mul(unity_WorldToObject,?float4(worldRayDir,?0.0));
o.rayOrigin?=?mul(unity_WorldToObject,?float4(worldRayOrigin,?1.0));//?不要擔心,我稍后會展示整個頂點著色器
現在,我們得到了一個可以正確處理正交投影和透視投影的頂點函數!片段著色器中不需要更改任何內容來考慮這一點。哦,我們實際上可以使用同一個函數來表示陰影投射器通道和前向照明通道。現在,你也可以使用正交相機了!
陰影偏差
現在,如果你一直在關注,你將得到一個輸出深度的陰影投射器通道。但我們沒有調用陰影投射器通常用于應用偏移的任何常用函數。目前,這并不明顯,因為我們還沒有進行自陰影,但如果我們不修復它,這將是一個問題。
我們不會使用內置的?TRANSFER_SHADOW_CASTER_NORMALOFFSET(o)
?宏來表示頂點著色器,因為我們需要在片段著色器中進行偏差。幸運的是,在物體空間中進行光線追蹤還有另一個好處。陰影投射器頂點著色器宏調用的第一個函數假設傳遞給它的位置位于物體空間中!我的意思是,這是有道理的,因為它假設它正在處理起始的物體空間頂點位置。但這意味著我們可以直接使用陰影投射器宏調用的偏差函數,使用我們光線追蹤的位置,它們就會正常工作!
?
是的,實際上仍然只是一個四邊形。
Tags?{?"LightMode"?=?"ShadowCaster"?}ZWrite?On?ZTest?LEqualCGPROGRAM
#pragma?vertex?vert
#pragma?fragment?frag_shadow#pragma?multi_compile_shadowcaster//?是的,我知道頂點函數缺失fixed4?frag_shadow?(v2f?i,out?float?outDepth?:?SV_Depth)?:?SV_Target
{//?光線起點float3?rayOrigin?=?i.rayOrigin;??//?歸一化光線向量float3?rayDir?=?normalize(i.rayDir);??//?光線球體相交float?rayHit?=?sphIntersect(rayOrigin,?rayDir,?float4(0,0,0,0.5));??//?上面的函數在沒有相交時返回?-1clip(rayHit);??//?計算物體空間位置float3?objectSpacePos?=?rayDir?*?rayHit?+?rayOrigin;??//?輸出修改后的深度//?是的,我們將?objectSpacePos?作為兩個參數傳遞//?第二個用于物體空間法線,在本例中//?是歸一化的位置,但該函數將其轉換為//?世界空間并進行歸一化,所以我們不必這樣做float4?clipPos?=?UnityClipSpaceShadowCasterPos(objectSpacePos,?objectSpacePos);clipPos?=?UnityApplyLinearShadowBias(clipPos);outDepth?=?clipPos.z?/?clipPos.w;??return?0;
}
ENDCG
就是這樣。這適用于所有陰影投射器變體。方向光陰影、聚光燈陰影、點光源陰影以及相機深度紋理!你知道,如果我們想支持多個燈光……
** 我沒有添加對 GLES 2.0 點光源陰影的支持。這需要將距離光源的距離作為陰影投射器通道的顏色值輸出,而不是僅僅硬編碼?*0*
。添加它并不難,但這會使著色器變得更加混亂,因為需要添加一些?*#if*
?和我們需要計算的特殊情況數據。所以我沒有包含它。*
** 編輯:我忘記了在處理 OpenGL 平臺上的深度時的一件事。OpenGL 的剪切空間 z 是 -w 到 +w 的范圍,所以你需要執行一個額外的步驟將其轉換為片段著色器輸出深度所需的 0.0 到 1.0 的范圍。*
#if?!defined(UNITY_REVERSED_Z)?//?基本上只有?OpenGL
outDepth?=?outDepth?*?0.5?+?0.5;
#endif
陰影接收
所以現在我們得到了一個有效的陰影投射。那么陰影接收呢?這將進入 Unity 特定內容的陰暗面。如果你不是凡人,現在就轉身吧……或者,如果你不太關心 Unity 的內置前向渲染路徑。(或者至少跳到下一節關于 深度 的內容。)
點亮它
在早期,我發布了一個帶有基本漫反射光照設置的著色器。如果你一直關注這篇文章,那么前向基本通道的光照代碼現在應該看起來像這樣。
//?世界空間表面法線和位置
float3?worldNormal?=?UnityObjectToWorldNormal(objectSpaceNormal);
float3?worldPos?=?mul(unity_ObjectToWorld,?float4(objectSpacePos,?1.0));//?基本光照
half3?worldLightDir?=?UnityWorldSpaceLightDir(worldPos);
half?ndotl?=?saturate(dot(worldNormal,?worldLightDir));
half3?lighting?=?_LightColor0?*?ndotl;//?環境光照
half3?ambient?=?ShadeSH9(float4(worldNormal,?1));
lighting?+=?ambient;//?應用光照
col.rgb?*=?lighting;
沒什么特別的。獲取你的世界法線和世界位置。獲取世界光線方向。執行一個鉗位點積。將光線顏色乘以點積,添加環境光照,并將紋理乘以光照。這有點像你開始學習光照著色器教程時的代碼。但我們顯然缺少陰影。
對于傳統的向前基本照明著色器,我們想要在一些地方添加一些宏,Unity 會自動為我們提供所需的內容。將?SHADOW_COORDS(#)
?添加到?v2f
?結構體中,在頂點函數中調用?TRANSFER_SHADOW(o);
,然后在片段著色器中調用?UNITY_LIGHT_ATTENUATION(atten, i, worldPos);
。我們當然可以這樣做,至少對于向前基本通道來說可以這樣做。在桌面和主機上,Unity 的方向光的陰影使用屏幕空間陰影。也就是說,陰影貼圖被渲染,然后它們被投射到從相機深度紋理中事先計算出的世界位置上,并保存在屏幕空間紋理中。所以上面的宏只是將屏幕空間位置傳遞下去,你可以從剪切空間位置中廉價地計算出它。
通常,這是通過上面提到的?TRANSFER_SHADOW(o);
?宏來完成的,并從頂點著色器傳遞到片段著色器。但我們已經在片段著色器中計算了剪切空間位置。我們可以重復使用它,使用宏調用的同一個?ComputeScreenPos(clipPos)
?函數來計算屏幕空間位置。然后,我們可以使用最終的內置宏,讓它完成剩下的工作。
我們確實想要使用?UNITY_LIGHT_ATTENUATION(atten, i, worldPos);
?宏。它為我們處理額外的功能,例如光線餅干。以及另一個我將在稍后提到的原因。
但有一個小問題。內置的陰影宏期望你傳遞一個包含屏幕空間位置的結構體。而我們的?v2f
?結構體沒有它,如果我們不必這樣做,我們也不想把它添加到該結構體中。
謝天謝地,我們不需要這樣做,我們可以創建一個虛擬結構體!它只需要?SHADOW_COORDS(0)
?宏來添加其他宏期望的結構體元素,然后我們就可以自己設置它添加的值。
//?虛擬結構體
struct?shadowInput?{SHADOW_COORDS(0)
);//?世界空間位置和剪切空間位置
float3?worldPos?=?mul(unity_ObjectToWorld,?float4(surfacePos,?1.0));
float4?clipPos?=?UnityWorldToClipPos(float4(worldPos,?1.0));#if?defined?(SHADOWS_SCREEN)
//?為屏幕空間陰影設置陰影結構體
shadowInput?shadowIN;
#if?defined(UNITY_NO_SCREENSPACE_SHADOWS)
//?移動陰影
shadowIN._ShadowCoord?=?mul(unity_WorldToShadow[0],?float4(worldPos,?1.0));
#else
//?屏幕空間陰影
shadowIN._ShadowCoord?=?ComputeScreenPos(clipPos);
#endif?//?UNITY_NO_SCREENSPACE_SHADOWS
#else
float?shadowIN?=?0;
#endif?//?SHADOWS_SCREEN//?宏創建一個名為?atten?的變量,其中包含陰影
UNITY_LIGHT_ATTENUATION(atten,?shadowIN,?worldPos);//?將方向光照乘以?atten
half3?lighting?=?_LightColor0?*?ndotl?*?atten;
現在,我們可以接收方向陰影了!
?
捕捉陰影。
多個燈光
所以我說過我們確實想要使用上面的?UNITY_LIGHT_ATTENUATION
?宏。這是真正的原因。它還處理其他燈光類型!Unity 的內置前向渲染器通過為每個燈光再次渲染物體來繪制多個燈光。所以我們需要一個前向添加通道。而我們現在用于前向基本通道的唯一的阻止它與前向添加通道一起工作的東西是環境光照。所以你可以復制片段著色器函數并刪除兩行環境光照代碼。
或者,你可以在?#if defined(UNITY_SHOULD_SAMPLE_SH)
?中放置這三行環境光照代碼,它只對基本通道為真。然后,你可以為這兩個通道共享完全相同的函數。
?
RTX 關閉!
碎片深度
使用?SV_Depth
?有一個很大的問題。它禁用了早期深度拒絕。基本上,這意味著如果你在視錐體中,你將支付渲染Imposter的成本。即使它位于其他東西的后面,并且不可見。通常,GPU 可以使用深度緩沖區來跳過對位于相機更近的其他物體后面的網格運行片段著色器。但由于 GPU 在片段著色器運行之后才知道深度是多少,因此它無法做到這一點。
“那么?
SV_DepthLessEqual
?或?SV_DepthGreaterEqual
?呢?”
是的!這是一個很棒的問題,佩蒂尼奧先生。你怎么知道(https://mynameismjp.wordpress.com/2010/11/14/d3d11-features/) 我在想這個?
保守深度輸出
SV_DepthLessEqual
?和?SV_DepthGreaterEqual
?語義是?SV_Depth
?的替代品,它們告訴 GPU 仍然執行早期深度拒絕,這是為著色器模型 5.0 添加的。但是要使用它,我們必須確保網格比我們要渲染的球體更靠近或更靠近相機。為此,我們想要將網格拉向相機。現在,面向相機的四邊形位于球體的中心。
問題是我們需要將頂點移近相機,而不會修改它們的屏幕空間位置。我們已經為它們計算出了完美的邊界,所以如果我們最終取消了這些操作,那就很不幸了。
一個選擇是計算比球體樞軸更靠近相機?maxRadius
?的視平面的剪切空間位置。然后替換已經計算出的剪切空間位置的 z。剪切空間有一個非常酷的功能,你可以更改剪切空間位置的 z,而不會影響它在屏幕上的位置或導致插值問題。
//?著色器末尾的常用剪切空間
o.pos?=?UnityWorldToClipPos(worldPos);//?獲取球體樞軸沿?//?前進視圖向量更靠近?`maxRadius`?的位置
float4?nearerClip?=?UnityWorldToClipPos(worldSpacePivotPos?—?worldSpaceViewForward?*?maxRadius);//?轉換應用“透視除法”以獲取真實的深度?Z
float?nearerZ?=?nearerClip.z?/?nearerClip.w//?用新的值替換原始的剪切空間?z
o.pos.z?=?nearerZ?*?o.pos.w;
但這種技術有一個很大的缺陷。如果你將相機移得太靠近或試圖穿過我們的Imposter球體,那么當我們應該仍然看到它時,它就會消失。問題是“更近的深度”被放置在相機的后面。我們可以嘗試對此進行更多工作。例如,嘗試將 z 限制為近平面。或者,更確切地說,是將 z 限制在近剪切平面的內部,因為在近剪切平面上仍然會導致它被剔除。
//?限制為近剪切平面的內部
o.pos.z?=?min(o.pos.w?-?1.0e-6f,?nearerZ?*?o.pos.w);
但……這實際上并沒有按預期工作*。
當我 說你可以更改剪切空間位置的 z 而不會出現任何問題時,我撒了點謊。這在一種情況下會失敗,那就是當網格的某些頂點位于相機后面時。我們試圖解決的正是這種情況。即使進行了鉗位,四邊形仍然比它應該的更被剪切。所以這失敗了。
?
老實說,我不太了解這個問題,無法解釋原因。
但有一個更便宜的解決方案,它在一般情況下表現良好,并且不會在“某些頂點位于相機后面”的情況下失敗!我們可以沿著光線方向將頂點移動一個球體半徑。對于正交投影,這實際上只是世界位置減去前進視圖乘以球體半徑。對于透視投影,如果我們使用歸一化的光線方向,它實際上不會拉得足夠遠。所以我們需要再次調用我們的朋友?dot()
,以找出我們需要偏移多遠才能正確地將四邊形的表面拉近一個球體半徑。
//?這將頂點推向相機
//?在頂點著色器中的?UnityWorldToClipPos?行之前添加
worldPos?+=?worldSpaceRayDir?/?dot(normalize(viewOffset),?worldSpaceRayDir)?*?maxRadius;//?著色器末尾的常用剪切空間
o.pos?=?UnityWorldToClipPos(worldPos);
現在,當你的相機靠近時,它仍然會與球體進行近剪切,但結果與剪切實際球體網格非常相似。一般來說,如果網格沒有被剪切,那么光線偏移四邊形也不會被剪切。
?
添加了這一點之后,只需要將片段著色器中的?SV_Depth
?語義替換為適當的選項。對于任何不是?OpenGL 的內容,你應該使用?SV_DepthLessEqual
。這是因為 Unity 為非 OpenGL 平臺使用反向 Z 深度。反向 Z 深度意味著距離更遠的物體具有比更近的物體更小的深度值。所以實際上,我們只需要檢查?UNITY_REVERSED_Z
?關鍵字是否處于活動狀態。對于 OpenGL……好吧,實際上這都是無用的。我們無法保證 OpenGL 平臺支持與?SV_DepthGreaterEqual
?等效的功能,直到 OpenGL 4.2。 基本上,你可能被迫在任何不使用反向 Z 深度的平臺上使用?SV_Depth
。然后,所有這些將四邊形拉近相機以減少過度陰影的操作對于這些平臺來說都是毫無意義的。但我們至少可以在著色器中處理這兩種情況。
** 編輯:運行 OpenGL 4.2+ 的 Unity 仍然使用常規的 z 深度。你可以為它使用?*SV_DepthGreaterEqual*
,但實際上,任何支持 OpenGL 4.2 的平臺,你都希望改為運行 Direct3D、Vulkan 或 Metal。*
//?這樣更新片段著色器函數
half4?frag_(forward/shadow)?(v2f?i
#if?UNITY_REVERSED_Z?&&?SHADER_TARGET?>?40,?out?float?outDepth?:?SV_DepthLessEqual
#else
//?該設備可能無法使用保守深度,?out?float?outDepth?:?SV_Depth
#endif)?:?SV_Target
收尾工作
還有一些小細節需要完善著色器。支持“每個頂點”非重要燈光、霧和基本實例化。這些并不十分有趣,所以我將快速介紹一下。
“每個頂點”非重要燈光
由于我們實際上沒有很多頂點,所以我們還需要在片段著色器中調用“頂點燈光”函數。這實際上只是復制和粘貼頂點燈光函數,將其放在一個?#if
?中,并將返回值添加到?lighting
?中。
#if?defined(VERTEXLIGHT_ON)
//?“每個頂點”非重要燈光
half3?vertexLighting?=?Shade4PointLights(unity_4LightPosX0,?unity_4LightPosY0,?unity_4LightPosZ0,unity_LightColor[0].rgb,?unity_LightColor[1].rgb,unity_LightColor[2].rgb,?unity_LightColor[3].rgb,unity_4LightAtten0,worldPos,?worldNormal);lighting?+=?vertexLighting;
#endif
或者至少它應該這么簡單。VERTEXLIGHT_ON
?是由?#pragma multi_compile_fwdbase
?控制的關鍵字之一。但似乎,如果你在頂點著色器中沒有這個函數,那么具有該關鍵字的著色器變體將永遠不會創建。所以你必須用自己的多編譯行來強制執行它。
#pragma?multi_compile?_?VERTEXLIGHT_ON
霧
與這篇文章中介紹的許多內容一樣,Unity 的內置宏假設你正在從頂點著色器中輸出某種值。對于桌面,這只是將原始的?clipPos.z
?傳遞給片段著色器,然后片段著色器在調用那里的霧宏時計算實際的霧衰減。所以,我們可以在前向通道的片段著色器末尾添加帶有?UNITY_APPLY_FOG(clipPos.z, col);
?的常用宏。
對于移動設備,衰減是在頂點著色器中計算的。但我們需要使用我們在片段著色器中計算的?clipPos.z
,所以如果你想要同時支持移動設備和桌面,我們不能只使用常用的?UNITY_APPLY_FOG(clipPos.z, col)
?宏。所以我們必須計算衰減并將它傳遞給宏,但只在移動設備上這樣做。
//?霧
float?fogCoord?=?clipPos.z;#if?(SHADER_TARGET?<?30)?||?defined(SHADER_API_MOBILE)
//?宏計算霧衰減
//?并創建一個?unityFogFactor?變量來保存它
UNITY_CALC_FOG_FACTOR(fogCoord);
fogCoord?=?unityFogFactor;
#endifUNITY_APPLY_FOG(fogCoord,?col);
實例化
要將實例化添加到著色器中,請復制和粘貼 Unity 關于此內容的文檔中提到的適當宏:
GPU 實例化
https://docs.unity3d.com/Manual/GPUInstancing.html
轉到將實例化添加到頂點和片段著色器部分,并將宏復制到?appdata
?和?v2f
?結構體、頂點函數以及片段函數中。忽略?BUFFER
?和?PROP
?宏。但你確實需要在片段著色器中使用?UNITY_SETUP_INSTANCE_ID(i);
。在實例化著色器中,unity_ObjectToWorld
?和?unity_WorldToObject
?矩陣是實例化屬性。由于我們在片段著色器中使用它們,因此我們也需要實例 ID。
完成的著色器
話不多說,這是完成的著色器,完整代碼如下。
完整代碼(https://gist.github.com/bgolus/1188cd89968b977d5c468bf7bbb3250b)
其他想法
表面著色器和著色器圖
因為我知道下一個問題每個人都會問的是“如何在表面著色器/著色器圖中做這個?”。以下是這些問題的答案。
你不能。*
好吧,你可以構建光線起點和方向。你可以進行球體的光線追蹤。你當然也可以執行所有過程式 UV 操作。你甚至可以更新表面法線,使其像球體一樣被照亮。
你不能做的一件事是從片段著色器中調整用于光照和陰影的深度或世界位置。因此,深度相交看起來會很奇怪,陰影看起來會很奇怪,并且非常靠近表面的燈光看起來也不正確。因為它們都將使用原始網格表面的位置。
因此,在 Unity 的任何渲染器中使用這種技術的唯一選擇是使用手寫的頂點片段著色器。至少目前是這樣。我希望有一天你能夠在著色器圖中輸出修改后的深度值。但截至撰寫本文時,他們還沒有提到要添加此功能。
** 人們指出,HDRP 的著色器圖確實具有在主節點上設置深度以執行每個片段深度功能的能力。不過,它使用的是?*SV_Depth*
?而不是?*SV_DepthLessEqual*
,因此不需要執行四邊形的射線方向偏移。感謝?Rémy?提醒我。希望他們能將此功能添加到 URP 中。
https://portal.productboard.com/unity/1-unity-graphics/tabs/7-shader-graph
抗鋸齒
我的許多其他文章都是關于抗鋸齒的,為什么我在這里跳過了它?因為這是一個沒有完美解決方案的難題。
Inigo Quiles 在這里有一個關于如何處理光線追蹤球體的抗鋸齒的優秀示例:
https://www.shadertoy.com/view/MsSSWV
基本原理是使用光線到點距離計算(這也用于修復外部邊緣的 UV),以近似地了解光線在屏幕空間中距離球體邊緣有多近。這可以為你提供一個漸變,可以使用類似于我在 Alpha to Coverage 文章中使用的函數來銳化,然后將其用作輸出 alpha。也可以用于非 MSAA 和非不透明用例中的 alpha 混合。
?
使用原始著色器的 4x MSAA 與使用 Alpha to Coverage 的比較。
//?將此添加到通道中,位于?CGPROGRAM?之外,以啟用
//?alpha?to?coverage
AlphaToMask?On
//?光線到球體樞軸距離
float?rayToPointDist?=?length(rayDir?*?dot(rayDir,?-rayOrigin)?+?rayOrigin);//?fwidth?獲取?ddx?和?ddy?偏導數的總和
//?float?fDist?=?fwidth(rayToPointDist);//?fwidth?是對此的粗略近似
float?fDist?=?length(float2(ddx(rayToPointDist),?ddy(rayToPointDist)));//?銳化光線到點距離
//?以球體半徑為中心,根據導數?+/-?半個像素
float?alpha?=?(0.5?-?rayToPointDist)?/?max(fDist,?0.0001)?+?0.5;//?根據銳化的?alpha?剪切
//?不要根據光線命中未命中進行剪切
clip(alpha);//?將?alpha?限制在?0?到?1?的范圍內,并在
//?采樣紋理后將其應用于輸出?alpha
col.a?=?saturate(alpha);
這似乎應該足夠好了,對吧?那么為什么我說沒有完美的解決方案呢?為什么我沒有默認實現它呢?對外部邊緣進行抗鋸齒并不能解決與光柵化網格或從片段著色器輸出深度的其他著色器相交時的鋸齒問題。當啟用 MSAA 光柵化三角形時,會為三角形覆蓋的每個子樣本計算深度,但片段著色器只對每個像素運行一次。這意味著兩個相交網格的每個子樣本覆蓋可以準確地確定到子樣本計數。此著色器正在從片段著色器中寫入深度,因此每個像素只有一個深度。然后,相同的深度用于所有子樣本。因此,相交處沒有 AA。從技術上講,在光柵化幾何體和輸出深度的片段著色器之間仍然存在一些?AA,因為會考慮相交三角形的平面。但在兩個深度寫入著色器之間將不會存在任何 AA。
?
使用原始著色器的 4x MSAA 與使用 Alpha to Coverage 的比較。請注意,兩種方法的相交處都是相同的。與視平面對齊的光柵化表面在與Imposter相交處顯示鋸齒。以角度觀察的光柵化表面顯示抗鋸齒,但它等效于與視平面對齊的表面相交。
上面的 Shadertoy 示例可以處理相交,因為它在一個通道中渲染所有這些球體,并對分析形狀執行每個像素排序和合成。它甚至沒有執行任何 MSAA。
據我所知,沒有一種有效的方法可以在啟用 MSAA 的情況下處理片段著色器深度寫入,同時仍然只對每個像素運行一次片段著色器。這將導致使用?sample
?插值修飾符來強制片段著色器對每個子樣本運行。當 MSAA 的全部目的是不這樣做時,這對于性能來說并不理想。但它看起來確實很不錯。
?
使用原始著色器的 4x MSAA 與強制每個子樣本渲染的著色器的比較。
?
使用原始著色器的 4x MSAA 與強制每個子樣本渲染的著色器的比較。請注意,超級采樣情況下的所有相交處都得到了適當的抗鋸齒。
//?更新?v2f?結構體以使用插值的?ray?dir?和?ray?origin?向量的樣本修飾符,以強制片段
//?著色器對每個子樣本運行,并為插值
//?值獲取每個子樣本位置的唯一計算
struct?v2f
{float4?pos?:?SV_POSITION;sample?float3?rayDir?:?TEXCOORD0;sample?float3?rayOrigin?:?TEXCOORD1;UNITY_VERTEX_INPUT_INSTANCE_ID
};//?將此添加到?CGPROGRAM?塊中,作為通道,因為
//?樣本修飾符是著色器模型?5.0?的功能
#pragma?target?5.0//?你可能還想對紋理?mip?層級進行偏差
//?因為如果我們已經進行了超級采樣,為什么不呢!
half4?col?=?tex2Dbias(_MainTex,?float4(uv,?0,?-1));
?
Alpha to Coverage 的 4x MSAA 與強制每個子樣本渲染的著色器的比較。
?
原始著色器的 4x MSAA 與 Alpha to Coverage 與強制超級采樣相交比較。
延遲渲染
我沒有在示例著色器中包含延遲渲染通道。沒有理由認為這不能與延遲渲染一起使用。它甚至會更容易編寫。但我試圖使著色器盡可能簡單。
?想了解更多游戲開發知識,可以掃描下方二維碼,免費領取游戲開發4天訓練營課程