![9b5b0956b0ddfc53ea3a622a7116c834.png](https://i-blog.csdnimg.cn/blog_migrate/c0eb41b87e4970af1e39541b3ef45025.jpeg)
本文谨代表本人的理解,旨在抛砖引玉,如有错误或建议,还望大佬们指教。
0、前言
提到体算法,主机端pc端一般采用rayMarching思想进行模拟。这方面可以查阅的资料已经很多了。本人在不久前参考这些文章,在unity中做出了简单的效果。
但毕竟从事手游开发,技术需要落地到移动平台。在移动端的实现和主机pc端有所不同,后续也许要做一些额外的优化。最终在手边最差的机器maliG51(麒麟710)上跑满了60帧,体积雾耗时2ms+-0.2ms,另外通过调参还有更多优化空间。
由于体积算法的文章已经有很多了,但在移动平台的实现还是空白。因此本文将不再过多赘述具体的算法细节,而是以在移动端跑满60帧为出发点,一切皆能省则省,阐述一下本人在移动端的实现与主机端pc端的差别,以及一些后续的优化方法。
抛砖引玉,还望大家多提意见。
1、先提出本人的结论
体积雾的实现采用:rayMarching+2dFractalNoise
优化采用:downSample+jitter+temporalFilter+blur+edgeResample
由于移动平台的一些限制,首先在形态模拟就无法得到云的细节,再加上一系列会优化后(后文均会提到),个人认为作为云来讲,形状和边缘细节损失比较大。不过用来模拟雾效,个人觉得效果还是不错的。
作为程序审美有限,最终配合大气散射,高度雾,做出了沙漠中黄沙滚滚的效果。并在mi10pro上跑满了60帧。我们先看视频:(没想到视频压缩这么厉害,具体的细节还是看图吧)
![df9b3f9388da8c9af7912a88fa660345.png](https://i-blog.csdnimg.cn/blog_migrate/a6cf372cfb73a4fb4d5424de31cfb8e4.jpeg)
![a839d765fc1c7adf9e237f7345649a2c.gif](https://i-blog.csdnimg.cn/blog_migrate/6a4a0577dcf0c24c9271e35e1cb8af1d.gif)
![257214ec095a78e8aa8749ecac163e73.png](https://i-blog.csdnimg.cn/blog_migrate/5d283fc73e824d6780e5f19f5d063366.jpeg)
![9cb36af968de8c1207de6f9f330477de.png](https://i-blog.csdnimg.cn/blog_migrate/f40fd6af13749eafc3546bf432c4af1e.jpeg)
![f661220e09f1ca899e7c0618c2e7a734.png](https://i-blog.csdnimg.cn/blog_migrate/6a5691e109e3fd7b40e0b7aa939584fe.png)
使用最费的沙尘暴效果进行测试。机器有限,仅测试了mi10pro和小米mix2华为nova4e。
mi10p作为新一年的旗舰机自然效果全开,体积雾也直接上了两层。大气散射+高度雾+双层体积雾。跑满60帧。其中体积雾耗时<1ms。
另外还测试了小米mix2华为nova4e。无散射+unity原生指数雾+单层体积雾。跑满60帧。其中在较低配的nova4e中体积雾耗时2ms+-0.2ms。
2、RayMarching的原理
由于rayMarching的文章已经比较多了,本文不再对其中的细节做过多的赘述。具体的思路以及流程,可以参考链接里的FMX演讲,仅有22分钟,言简意赅,非常值得观看。
FMX2017 Technical Directing Special: Real-time Volumetric Cloud Renderingwww.youtube.com将talk简单的概括一下,就是从相机方向,每个像素向世界方向产生射线。射线在包围盒内,每隔一段距离进行采样与计算。直到射线总距离超过包围盒的最远距离或深度图的距离。至于云的形状,则采用Worley噪声作为大型,Perlin噪声修整边缘。(talk中穿插了循环噪声生成,后续还采用蓝噪声与TAA做步长优化)
![3da6ce2c4a2ed16b732f44e5f6865992.png](https://i-blog.csdnimg.cn/blog_migrate/393f303aa4948a1c9bf90301c6405503.jpeg)
3、实现
3.1 拿到深度图
之前在知乎上看到了免费拿到深度图的方法。了解了一下后发现,只需要首尾帧调用如下方法就可以了,不需要每一帧重新set。此外,也如链接中所述,由于拿走了backBuffer。需要添加后处理相机,进行颜色输出与后处理渲染。
public void InitBuffer(Camera bufferCam)
{
if(colorBufferRT == null)
{
colorBufferRT = RenderTexture.GetTemporary(bufferCam.pixelWidth, bufferCam.pixelHeight, 0);
depthBufferRT = RenderTexture.GetTemporary(bufferCam.pixelWidth, bufferCam.pixelHeight, 24, RenderTextureFormat.Depth);
}
bufferCam.SetTargetBuffers(colorBufferRT.colorBuffer, depthBufferRT.depthBuffer);
}
public void ReleaseBuffer(Camera bufferCam)
{
if (colorBufferRT != null)
{
RenderTexture.ReleaseTemporary(colorBufferRT);
RenderTexture.ReleaseTemporary(depthBufferRT);
}
bufferCam.targetTexture = null;
}
![923f73dea6b5e9a4faa1a52956a3b9b1.png](https://i-blog.csdnimg.cn/blog_migrate/fd9f9c7561c7710aed702b69e55710f1.jpeg)
3.2 Marching!
首先,从结论出发,我们已经把体算法用作雾效了。所以不能再和云的渲染一样,放到天空中,只用高度作为界限。我们可以在世界空间内定义aabb盒,使用射线与aabb盒求交。从而更方便的定义雾效的作用范围。另外,还可以加入雾效半径,通过限制rayMarching前进距离减少开销。
![8c55cc817815fab6a48e7c22324f71ae.png](https://i-blog.csdnimg.cn/blog_migrate/ced9c7e9b43061fc216a9ea14f711d61.png)
使用slabMethod计算相交区域
Fast, Branchless Ray/Bounding Box Intersectionstavianator.com![39ba920827ff3950a24dff06efd70d45.png](https://i-blog.csdnimg.cn/blog_migrate/5d1d3ccb7456169dcd79c8498f6398fa.jpeg)
inline float2 rayBoxDst(float3 boundsMin, float3 boundsMax, float3 rayOrigin, float3 invRaydir) {
float3 t0 = (boundsMin - rayOrigin) * invRaydir;
float3 t1 = (boundsMax - rayOrigin) * invRaydir;
float3 tmin = min(t0, t1);
float3 tmax = max(t0, t1);
float dstA = max(max(tmin.x, tmin.y), tmin.z);
float dstB = min(tmax.x, min(tmax.y, tmax.z));
float dstToBox = max(0, dstA);
float dstInsideBox = max(0, dstB - dstToBox);
return float2(dstToBox, dstInsideBox);
}
根据深度图判断遮挡,根据采样半径限制距离
float3 ro = _WorldSpaceCameraPos;
float3 rd = normalize(rayXYZ);
// 计算得到距包围盒的最短/最长距离
float2 rayToContainerInfo = rayBoxDst(boundsMin, boundsMax, ro, 1 / rd);
float dstToBox = rayToContainerInfo.x;
float dstInsideBox = rayToContainerInfo.y;
// 距离还需要考虑深度图
float depthWithoutLinear = SAMPLE_DEPTH_TEXTURE(_MainDepthTex, uvDepth);
float depth = LinearEyeDepth(depthWithoutLinear) * length(rayXYZ);
// 前进到包围盒的最大距离or遇到了深度图
float dstLimit = min(depth - dstToBox, dstInsideBox);
// 限制距离相机半径以节省性能
dstLimit = min(fogRadius - dstToBox, dstLimit);
float3 entryPoint = ro + rd * dstToBox;
3.2 形状
*云和雾的分水岭
3.2.1 3d还是2d
一般来说,在模拟云层形状时,会采用3d噪声,通过采样3d噪声模拟云的形状,增加云在垂直方向的层次感。
这就带来了一些问题。首先不是所有es2.0都支持tex3D。不过这也不是大问题,因为3d噪声可以通过切片的形式使用tex2D采样切片。但不论是带宽限制,还是采样开销,移动端都架不住每个步长获取一个三维空间浓度信息。
因此在这里使用了一个通道的2d,只进行xz平面的形状模拟。如图:
![1b6fa86fd19d49738548cce5601b986c.png](https://i-blog.csdnimg.cn/blog_migrate/8e6dee312059c55d374d6245e669c7fb.jpeg)
这里特地找到了迪士尼提供的3d噪声,用来对比只有一个通道的2d噪声。可以看到第一步就被甩了几条街...已经和云渐行渐远...更不用提地平线在不同大气高度模拟不同形状的云了。
3.2.2 Y轴怎么办
由于省掉3d噪声没有y轴信息,水平方向只能这样笔直的垂下来,像是柱形。为了解决这一现象,我们可以对y轴添加形状信息。这里使用了最简单的一元一次方程进行模拟。如此也避免了每个采样点额外采样一张高度ramap的操作。
float getHeightGradient(float heightPercent) {
float heightGradientUp = HeightUpDownKB.x * heightPercent + HeightUpDownKB.y;
float heightGradientDown = HeightUpDownKB.z * heightPercent;
float heightGradient = saturate(heightGradientDown) * saturate(heightGradientUp);
return heightGradient;
}
![eadd56056a4ed588771d083a2cda98c5.png](https://i-blog.csdnimg.cn/blog_migrate/67546a474ba577fe8be30938c04b6cb5.jpeg)
3.2.3 分形噪声
在云雾的运动中可以采用分形噪声的思想,使用不同速度、不同频率的噪声叠加模拟流动效果。算是比较常见的做法了。
fractals, computer graphics, mathematics, shaders, demoscene and morewww.iquilezles.org![46008d3f4f963ae0cbcd97583ec0fc55.png](https://i-blog.csdnimg.cn/blog_migrate/9f8643be182e88a1431823418a4f8531.png)
![ebaf30e3ee98d8191a2c3140ef240885.gif](https://i-blog.csdnimg.cn/blog_migrate/9be4d684295cbb5ef082b386ef8eee89.gif)
3.3 颜色
*移动端和主机pc端的第二处不同
这里先不看光照公式。实际上,上面的采样只得到了云在视方向的浓度。光照的计算还需要在上面的每个采样点,向光源方向采样,得到光源方向浓度信息,再结合光照公式计算光照强度。
由此可见,如果在计算中还向光源采样累加,那么复杂度将成倍增长。这显然不是我们希望看到的。
因此,这里将向光源的浓度直接离线写进了噪声图的g通道。在形状采样中直接读取g通道,模拟向光源方向的浓度。虽然无法与光照互动了,但省掉了很大一部分的开销。同时还可以留下b通道,供以后模拟heightmap使用。
可参考"添加raymarching"章节,这里是一个思路blog.csdn.net![7351a31836ef5e4169e09bb85bad369a.png](https://i-blog.csdnimg.cn/blog_migrate/ed6e0219e0f694283c391b1d395f18e9.png)
while (dstTravelled < dstLimit) {
float3 rayPos = entryPoint + rd * dstTravelled;
float4 density4 = samplerDensity4(rayPos, boundsMin, boundsMax);
if (density4.r > 0) {
// 假装采样了...
sumRG += FogLightingFunction(density4.r, density4.g);
![07fde24af5daee73bc4d4e312ab1489b.png](https://i-blog.csdnimg.cn/blog_migrate/d904931a9252cdeedbf83ca9bf7abc70.jpeg)
不过在最后,发现如果正常计算,阴影部分为黑色,会显得比较死板。使用两个颜色在光强结果中做差值。可以看到右图的暗部,实则为蓝紫色。
fixed4 getMixedFogColor(fixed sumR, fixed sumG) {
//...
float4 col = fixed4(sumR * CloudColor.rgb + (1 - sumR) * sumG * CloudSecondColor.rgb, sumG);
return col;
}
![83955bf3f3bba4e82d1852273c46d8a4.png](https://i-blog.csdnimg.cn/blog_migrate/60e2d95f48b6523885e2860ac17d7506.jpeg)
4、优化
至此,在省略了一堆操作后,体积雾的正常渲染就算是完成了。但是即使这样,在手机上还是开销很大的。因此还需要进一步优化。
4.1 步长
根据rayMarching的基本原理。每一次前进就是一次采样计算的开销。越密集的步长虽然会带来更好的效果,但同时也意味着更大的开销。
所以首先根本出发,增大步长以换取更好的性能。在这里,本文根据射线前进的距离增加步长,同样是使用了简单粗暴的一元一次方程。并限制最大采样次数以避免极端情况的出现。
while (dstTravelled < dstLimit) {
float3 rayPos = entryPoint + rd * dstTravelled;
float4 density4 = samplerDensity4(rayPos, boundsMin, boundsMax);
if (density4.r > 0) {
// ...
// 最大有效采样次数,避免平视时穿透云层采样次数过多
nowStepCount += 1;
if (nowStepCount > MaxStepCount) break;
}
// 简单的一元一次方程,随采样距离增大步长
stepSize = stepSizeKB.x * dstTravelled + stepSizeKB.y;
dstTravelled += stepSize;
}
![d2e338b1abcfb9467bef0e45b423f49b.png](https://i-blog.csdnimg.cn/blog_migrate/4e5d91a514d5d9b25237423fb07120c4.jpeg)
4.2 抖动+时间滤波器
在和步长相关的操作中,这两者基本都是同时出现的。搭配使用,可以在一定程度上消除步长过大出现断层。
抖动(jitter)在这里的作用是屏幕像素每个像素,每一帧产生一个随机数,对采样的起始长度做叠加。比如第一帧少采一点,第二帧多采一点。然后使用时间滤波器(temporalFilter),对两帧做融合,得到平滑的颜色信息。
// 在前进前,先抖动一定距离
float dstTravelled = frac(sin(uv.x - uv.y + _Time.y) * 999999);
// ...
// rayMarching
while (...
注意这里的时间滤波器与时间抗锯齿(TAA)还是有所区别的。完全没有TAA那么高级。这里只是作用于体积雾效果,将雾效的采样抖动做融合,其他都没有考虑。因此,这里也在一定程度上造成了雾的涂抹感。
// 这一帧的颜色
float4 thisCol = tex2D(_MainTex, i.uv);
// 向周围采样,得到这一帧颜色区间
float4 thisMinCol = GetThisFrameMinCol(i.uv);
float4 thisMaxCol = GetThisFrameMaxCol(i.uv);
// 根据深度图,反推上一帧uv
float depth = tex2D(_MainDepthTex, i.uv).r;
float4 worldPos = mul(CurVPInverseMatrix, float4(i.uv * 2 - 1, depth, 1));
float4 lastClip = mul(PreVPMatrix, worldPos);
float2 uv = lastClip.xy / lastClip.w;
uv = uv * 0.5 + 0.5;
float2 velocity = i.uv - uv;
float2 lastUV = i.uv - velocity;
// 根据上一帧uv拿到这个像素上一帧的颜色信息,同时给定范围限制
float4 lastCol = tex2D(_LastTex, lastUV);
lastCol = clamp(lastCol, thisMinCol, thisMaxCol);
// 融合
thisCol = lerp(thisCol, lastCol, LastFrameLerp);
return thisCol;
![c7ec98e96b5d8ec28c0d31ec45ea560d.png](https://i-blog.csdnimg.cn/blog_migrate/c82be3eb397984adf3e36fa4affd2ccc.jpeg)
4.3 降采样+边缘重绘
上文从rayMarching原理层面做了优化,然而最简单粗暴的,还是降采样。降到1/2就省了75%,十分可观。
然而低分辨率上采样时,会图片边缘,产生锯齿感。这时先对低分辨率的雾,或低分辨率的深度图做边缘检测。得到边缘信息回到高分辨率后再重绘。
不过边缘重绘在一些情况下带来了新的问题。比如当场景复杂度高时,边缘信息过多,高分辨的绘制还会带来额外开销。因此还有待测试。
Chapter 23. High-Speed, Off-Screen Particlesdeveloper.nvidia.com![6caba220c8a59b9685455cb03c6487bf.png](https://i-blog.csdnimg.cn/blog_migrate/e931c0b96676d2542f153730e6e3067f.jpeg)
![4103d13e3232e6a1b148c82272a842bf.png](https://i-blog.csdnimg.cn/blog_migrate/1b40168d606b32a37ed20ad3da4b2ac8.jpeg)
5. 体积云?体积雾
最后放到天空中,意外的发现,由于分形噪声的缘故,以及4.1中我们限制了采样步长。在某些角度的边缘居然意外的产生了层次感。然而截图也只是角度限定,由于云层较大较厚,和3d噪声还是差得远的。
![1d198f3e47bae0321851c7d95a877f9b.png](https://i-blog.csdnimg.cn/blog_migrate/b2ccb9fba843dfca3e495013b4c67ec9.jpeg)
![09b87c6a155dee0e273ae5962912b80c.png](https://i-blog.csdnimg.cn/blog_migrate/e05beba5b531cda1dea925b03b495509.jpeg)
不过放在地上作为雾效,由于不需要云层那么厚,垂直方向的形态不需要很复杂,还是相对好很多的。
![7fb37aa07f34fcc37491a0188cff8aed.png](https://i-blog.csdnimg.cn/blog_migrate/9a342307bea72873ca7b98e276294377.jpeg)
或者直接像题图一样用作沙尘暴
![5aedd5d312a15da7e53694fda78e33e9.png](https://i-blog.csdnimg.cn/blog_migrate/f5c834166deb7a373b778186d27e0a2d.jpeg)
![a3293b4fd74fade79d9b43d183aa5463.png](https://i-blog.csdnimg.cn/blog_migrate/63dd52133a50f8a48d6165623b85094b.jpeg)
参考文章
FMX2017
地平线
unity自定义深度图
AABB盒求交
分形噪声
离屏软粒子