Unity 体积光
思路
-
观察下面这副图可以发现,在明亮处光很明显,暗处(阴影中)没有明显的光,且越暗光越不明显
-
为了还原这一现象,可以想到的是根据目标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 - 根据片段深度重建片段的世界坐标