- 思路
- 基礎的RayMarching
- 獲取深度圖
- 重建世界空間
- 取樣陰影圖
- RayMarching
- 當前效果
- Dual Blur最佳化塊狀感
- Dual Blur
- 疊加模糊後的體積光和原圖
- 進化叭!RayMarching!
- 效能最佳化
- 最終效果
- Reference
思路
-
觀察下面這副圖可以發現,在明亮處光很明顯,暗處(陰影中)沒有明顯的光,且越暗光越不明顯
-
為了還原這一現象,可以想到的是根據目標pixel的陰影值來計算亮度。但如何營造光的體積感呢?這就需要用到光線追蹤!的思想rayMarching(光線步進)
與光追不同的是,光追是每個pixel,在場景中發射一根射線並不斷彈射,當彈射出場景或達到最大彈射次數時,累加每次彈射計算得到的顏色,最終該pixel返回該顏色值;而rayMarching特別之處在於,它不會彈射,而是每個pixel發射一根射線,該射線每次行走一定的距離step,每行走一次計算當前位置的陰影值並累加,當碰到遮擋物體或達到最大距離,就終止步進,最終得到的結果即為累加的陰影值
如下圖所示,紅色虛線代表光線走到過的位置,當走到這些位置時就取樣陰影圖並得到對應的陰影值,最後累加
基礎的RayMarching
知道怎麼做了,現在就來實現叭!
獲取深度圖
-
因為RayMarching中涉及到光線與物體的碰撞檢測,所以需要重建世界空間,而重建需要深度圖的幫助
-
注意:記得在URP配置中啟用"Depth Texture"
-
實現
float GetEyeDepth(float2 uv) { float depth = SAMPLE_TEXTURE2D_X(_CameraDepthTexture, sampler_CameraDepthTexture, uv).r; float eyeDepth = LinearEyeDepth(depth, _ZBufferParams); return eyeDepth; }
重建世界空間
-
沒什麼好說的很簡單
-
實現
o.positionSS = ComputeScreenPos(o.positionCS); float2 screenUV = i.positionSS.xy / i.positionSS.w; float3 ReConstructPosWS(float2 NDCuv) { half depth = SAMPLE_TEXTURE2D_X(_CameraDepthTexture, sampler_CameraDepthTexture, NDCuv); float3 rePosWS = ComputeWorldSpacePosition(NDCuv, depth, UNITY_MATRIX_I_VP); return rePosWS; }
取樣陰影圖
-
在前面說過,得到的光照與陰影值有關,所以這裡需要取樣陰影圖
-
實現
#pragma multi_compile _ _MAIN_LIGHT_SHADOWS //接受陰影 #pragma multi_compile _ _MAIN_LIGHT_SHADOWS_CASCADE //產生陰影 #pragma multi_compile _ _SHADOWS_SOFT //軟陰影 float GetShadow(float3 positionWS) { float4 shadowUV = TransformWorldToShadowCoord(positionWS); float shadow = MainLightRealtimeShadow(shadowUV); return shadow; }
RayMarching
-
實現
RayMarching
half3 GetLightShaft(float3 rayOrigin, half3 rayDir, float maxDistance) { half step = maxDistance / _MaxDepth; // 步長 half currDistance = 0.h; // 當前已經步進的距離 float3 currPos = rayOrigin; // 當前步進到的位置 half3 totalLight = 0.h; // 總光照值 UNITY_UNROLL(50); for(int i = 0; i < _MaxDepth; ++i) { currDistance += step; // 超出最大距離 if(currDistance > maxDistance) break; // 步進後新的位置 currPos += rayDir * step; // 求當前pixel的陰影值 totalLight += _Brightness * GetShadow(currPos); } Light mainLight = GetMainLight(); half3 mainLightDir = mainLight.direction; // 白天夜晚的光顏色不同 half3 result = totalLight * mainLight.color * lerp(_NightColor.rgb * _NightColor.a, _DayColor.rgb * _DayColor.a, saturate(mainLightDir.y)); return result; }
疊加RayMarching和原來的顏色
// RenderFeature中將原圖複製給MainTex cmd.CopyTexture(RTID.targetBuffer, ShaderIDs.mainTexID); // 將原RT複製給_MainTex // shader half3 sourceColor = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv).rgb; half3 lightShaft = GetLightShaft(rayOrigin, rayDir, totalDistance); half3 result = lightShaft + sourceColor;
當前效果
Dual Blur最佳化塊狀感
- 存在的問題:仔細觀看上圖,雖然有體積光的感覺,但是有明顯的硬線,這是因為step的步長大小不夠小,得到的結果不夠精準(和光追一個道理,彈射次數越多越精準)。但是step步長小了開銷又很高,真是頭疼怎麼辦呢?
- 解決方案:因為體積光屬於後處理,要用魔法打敗魔法,所以這裡可以採用模糊弱化硬線。出於效能考慮,這裡使用效能拔尖的Dual Blur
- 因為Dual Blur在這篇提到過,所以此處僅僅簡單展示一下
Dual Blur
-
實現:先只模糊體積光
// 16個降取樣和升取樣的TextureID struct BlurLevelShaderIDs { internal int downLevelID; internal int upLevelID; } static int maxBlurLevel = 16; BlurLevelShaderIDs[] blurLevel; // 初始化降取樣和升取樣的Render Texture blurLevel = new BlurLevelShaderIDs[maxBlurLevel]; for (int t = 0; t < maxBlurLevel; ++t) // 16個down level id, 16個up level id { blurLevel[t] = new BlurLevelShaderIDs { downLevelID = Shader.PropertyToID("_BlurMipDown" + t), upLevelID = Shader.PropertyToID("_BlurMipUp" + t) }; } // 用於建立 render texture RenderTextureDescriptor descriptor = renderingData.cameraData.cameraTargetDescriptor; // 降取樣 descriptor.width /= m_passSetting.m_downsample; descriptor.height /= m_passSetting.m_downsample; // 體積光影像作為down的初始影像 RenderTargetIdentifier lastDown = RTID.targetBuffer; // 計算down sample for (int i = 0; i < m_passSetting.m_passLoop; ++i) { // 建立down、up的Temp RT int midDown = blurLevel[i].downLevelID; int midUp = blurLevel[i].upLevelID; cmd.GetTemporaryRT(midDown, descriptor, FilterMode.Bilinear); cmd.GetTemporaryRT(midUp, descriptor, FilterMode.Bilinear); // down sample cmd.Blit(lastDown, midDown, m_Material, 3); // 計算得到的影像複製給lastDown,以便下個迴圈繼續計算 lastDown = midDown; // 每次迴圈都降低解析度 descriptor.width = Mathf.Max(descriptor.width / 2, 3); descriptor.height = Mathf.Max(descriptor.height / 2, 3); } // 計算up sample // 將最後一個的down sample RT ID賦值給首個up sample RT ID int lastUp = blurLevel[m_passSetting.m_passLoop - 1].downLevelID; // 第一個ID已經賦值 for (int i = m_passSetting.m_passLoop - 2; i > 0; --i) { int midUp = blurLevel[i].upLevelID; cmd.Blit(lastUp, midUp, m_Material, 4); lastUp = midUp; } cmd.Blit(lastUp, RTID.targetBuffer, m_Material, 4);
-
效果
疊加模糊後的體積光和原圖
-
實現:先儲存一張原貼圖在_SourceRT,再將blur得到的RT和SourceRT疊加即可
cmd.CopyTexture(RTID.targetBuffer, ShaderIDs.sourceBufferID); // 將原RT複製給_SourceTex cmd.Blit(lastUp, ShaderIDs.tempBufferID, m_Material, 4); // blur up sample cmd.Blit(ShaderIDs.tempBufferID, RTID.targetBuffer, m_Material, 5); // 合併 half4 AddPS(PSInput i) : SV_TARGET { half4 result = 0.h; half4 sourceTex = SAMPLE_TEXTURE2D(_SourceRT, sampler_SourceRT, i.uv); half4 blurTex = SAMPLE_TEXTURE2D(_TempBuffer, sampler_TempBuffer, i.uv); result += sourceTex + blurTex; return result; }
-
效果
可以看到塊狀感沒了
進化叭!RayMarching!
-
存在的問題:觀察上圖得到的效果還是不錯滴,但是!目前的體積光模型是一種經驗模型,並沒有遵循現實,如光照是線性疊加,而現實中光照是會衰減的,且衰減是非線性
-
解決方案:使用瑞利散射模型模擬光照衰減和散射
-
結論推導
出於效能考慮,這裡只討論和實現單次散射,多次散射很複雜
如下圖,假設圓以外屬於真空領域,光線從點C進入大氣,當光線打中點P的顆粒時,有且只發生一次散射,隨後向A方向行進
假設大氣以外不會發生散射,且不消耗能量,當光線到達點C時的強度為最大值\(I_{c}\),行進至點P時衰減為\(I_{p}\),那麼\(透光度T = \frac{I_{p}}{I{c}}\),可得:\(I_{p} = I_{c} * T(CP)\)
-
散射公式
光線在點P發生散射,散射公式:\(S(\lambda, \theta, h) = \beta(\lambda, h) * P(\theta)\),其中\(\beta\)表示對整個圓散射得到的結果,\(P\)表示沿視線方向散射的量
這裡直接給結論\(\beta(\lambda, h) = \frac{8\pi^3 * (n^2 - 1)^2}{3} \frac{\rho(h)}{N} \frac{1}{\lambda^4}\),\(\rho(h) = exp^{\frac{-h}{H}}\),其中\(\rho(h)\)表示高度h處的相對大氣密度,H參考高度瑞利散射為8400,h為距離海平面高度,N表示標準大氣壓下的粒子濃度,n表示空氣折射率,\(\lambda\)表示波長
雖然這個結論可以直接用,但為了更簡便,這裡再簡化一下:定義常數項\(K = \frac{2\pi^2(n^2 - 1)^2}{3N}\),可得\(\beta(\lambda, h) = \frac{4\pi * K}{\lambda^4} \rho(h)\)
而\(P(\theta) = \frac{3}{16\pi}(1 + cos\theta^2)\),其中\(\theta\)表示光線反方向與視線方向夾角 ,可得\(S(\lambda, \theta, h) = \frac{4\pi * K}{\lambda^4} \rho(h) P(\theta)\)
\(P(\theta)\)可以和\(4\pi\)抵消,可得\(P(\theta) = \frac{3}{4\pi}(1 + cos\theta^2)\)
最終可得\(S(\lambda, \theta, h) = \frac{K\rho(h) P(\theta)}{\lambda^4}\)
-
透光度
因為只考慮單詞散射,這裡將每次光線步進看作一次散射,那麼每次步進光強衰減\((1 - \beta)\)。而步進n次,透光度為\(T = e^{-\beta}\)
-
-
實現
因為\(S(\lambda, \theta, h)\)是一個與相對高度和波長相關的函式,但光線步進的距離較小,對於高度幾乎沒有影響,可以省去,所以出於效能考慮,散射函式的係數由一個常量代替
// 沿視線方向散射的量(密度函式) float GetP(float cosTheta) { return 0.0596831f * (1.f + Pow2(cosTheta)); } // 高度h處的相對大氣密度 float GetRho() { return exp(-_HeightFromSeaLevel / 8400.f); } // 散射函式 float GetScatter(float cosTheta) { return GetP(cosTheta) * _ScatterFactor; } // 透光度 float GetTransmittance(float distance) { return exp(-distance * _ScatterFactor * GetRho()); } half3 GetLightShaft(float3 viewOrigin, half3 viewDir, float maxDistance) { Light mainLight = GetMainLight(); half3 mainLightDir = mainLight.direction; half rayMarchingStep = maxDistance / _MaxDepth; // 步長 half currDistance = 0.h; // 當前已經步進的距離 float3 currPos = viewOrigin; half3 totalLight = 0.h; float scatterFun = GetScatter(dot(viewDir, -mainLightDir)); UNITY_UNROLL(50); for(int i = 0; i < _MaxDepth; ++i) { rayMarchingStep *= 1.02f; // 速率逐漸變大 currDistance += rayMarchingStep; if(currDistance > maxDistance) break; // 步進後新的位置 currPos += viewDir * rayMarchingStep; float shadow = GetShadow(currPos); // 求當前pixel的陰影值 totalLight += _Brightness * shadow * scatterFun * GetTransmittance(rayMarchingStep); } half3 result = totalLight * mainLight.color * lerp(_NightColor.rgb * _NightColor.a, _DayColor.rgb * _DayColor.a, saturate(mainLightDir.y)); return result; }
-
效果
16次迴圈即可得到不錯的效果
效能最佳化
-
為了得到一個能在移動端跑的體積光,還需進行一點效能最佳化,如何做呢?
-
光的變化頻率不高,也就是說如果進行部分clip,也不會很容易被識別出來,這裡採用棋盤格重新整理的方式來更新
-
降低計算光PASS的RT
-
-
實現
很簡單,在ps中clip即可
clip(channel.y%2 * channel.x%2 + (channel.y+1)%2 * (channel.x+1)%2 - 0.1f);
在RenderFeature降低RT解析度
RTID.targetBuffer = renderingData.cameraData.renderer.cameraColorTarget; RenderTextureDescriptor descriptor = renderingData.cameraData.cameraTargetDescriptor; descriptor.width /= m_passSetting.m_downsample; descriptor.height /= m_passSetting.m_downsample; cmd.GetTemporaryRT(ShaderIDs.tempBufferID, descriptor, FilterMode.Bilinear); cmd.Blit(RTID.targetBuffer, ShaderIDs.tempBufferID); // 將原RT複製給_SourceTex
最終效果
- 下圖有少許黑點應該是gif錄製軟體的問題
Reference
遊戲開發相關實時渲染技術之體積光
Unity Shader - 根據片段深度重建片段的世界座標
Unity URP管線實現超簡單RayMarching體積光(3)
[Rendering] 基於物理的大氣渲染
URP管線下的高效能移動端體積光