移动端云渲染的实现
发表于2018-11-22
先展示移动端云渲染的最终效果:
对于噪声这里不想深入讨论,感兴趣的可以去看论文或者冯女神的博客https://blog.csdn.net/candycat1992/article/details/50346469。
另外,我们使用的噪声其实是这个噪声:https://www.shadertoy.com/view/4sfGzS
这是Iq大神弄的一个噪声,效率和表现都算很好。
这些还不够,我们还要分析下基本的算法,看下地平线早期的云实现:
主要思想还是根据raymarching得到云的外形,然后加上光照。
接下来先一步一步实现整个过程,但因为手机上要考虑性能,所以会对完整的云进行大幅度阉割。
中间红色的部分就是我们摄像机的FOV,那么可以这么计算出水平的fov的tan
然而,如果你移动你摄像机的位置,你会发现这个云会出现各种异常。于是我们要一步一步解决问题。
首先是计算量的浪费问题,我们建立的云层模型是水平的,有顶部和底部组成,摄像机出发的射线,其实是经过顶部和底部的部分才有效果,那么其实我们可以直接从摄像机的顶部交点或者底部交点开始运算。如果位于摄像机里面,那么就是从摄像机位置开始运算。
这样子就可以解决第一个问题。
由于正常情况下云层一般较高,移动摄像机位置引起的变化量容易过大,导致一旦开始移动,采样贴图的坐标也迅速移动,导致云层瞬间变化。然而我们知道 ,摄像机的那点移动对于云来说不算什么,于是我增加了一个缩放系数,因为云层基本在几千米左右,我就把这个系数定位1000,然后根据和云层的距离再做一个基本的非线性关系。
可以看到,我都会根据顶部还是底部做不同的分支,这其实是我偷懒,可以用直线和线段求教简化这段代码,但现在就先这样,能实现功能优先。最后还有一个_cloudOffset和offset,我们后面再讲解。
接下来是边缘过度问题,云层的边缘超过顶部和底部会直接不计算,那么会导致一个难看的切边,于是我要根据顶部和底部增加一个透明过度。另外,如果云层很厚,我们将整个噪点分布在全部云层,会导致云层和稀薄,于是需要一个循环处理,我通过一个简单的线性周期函数来实现这个东西。
到这里,理论上应该可以了,但实际操作中发现离云很远的时候,云的噪点很厉害。想象一下在现实中,我们很远看云的时候,云的那些细节会逐渐变成较大块的纯色,边缘还是有细节。而我们根据位置进行那么远的采样,必然会导致都是噪点。我思来想去,觉得如果一致能维持一个较近的渲染效果,根据距离做一丢丢的变化,那就好了。于是我增加了_cloudOffset这个变量,会根据摄像机的位置和云的位置动态变化,保证云的渲染效果一直以较近距离观察为主。这样虽然在人靠近云的感受不太自然,但整体的感觉还可以接受。
最后,是光照。
这部分我我没有太多想法,而是直接使用了iq大佬的基本思想,云的边缘会变量。通过往光线方向采样,得到新的位置的云的密度,如果密度变小,说明越靠近云的边缘,就更亮。
具体实现的时候依然根据云的上层和下层做了区别,因为如果一个方向变量,那么另一个方向反而会变暗,所以需要反向一下y轴。
至此,我们大概实现了这样的云:
视频中还有一个穿越云层的效果,具体可以看视频内容。
最后说下这个云的问题。
这个云还远远没有到可使用的级别,第一,作为后处理特效,最早也只能在非透明物体渲染完成后,也就是云老是叠在非透明物体上。更合理的应该还是用CommandBuffer去做,可以控制渲染的时机。或者用一个面片去替代后处理,但是面片就无法降采样了,性能无法达到手机上的标准。所以还是需要用CommandBuffer改造这个云。
当然也可以自己设置深度缓存,这样子就可以实现云和物体的交融,但是性能当然就更耗了。
即便没有上述这些,这个手机场景demo在vivo x6手机上也只能跑30帧。那个穿越云层的大咖只有10帧。所以,还是需要更好一点的手机才能使用这种方式制作的云。
总而言之,还需要继续优化。
最后是插件地址:
https://www.assetstore.unity3d.com/en/?stay#!/content/133674
类似渲染云这种自然现象的时候,必须首先了解噪声这个概念。这个噪声指的是描述自然界规律的一些随机函数。例如大名鼎鼎的柏林噪声。Perlin噪声被大量用于云朵、火焰和地形等自然环境的模拟,而Worley噪声被提出用于模拟一些多孔结构,例如纸张、木纹等。不过其实Wroley噪声也可以用在云上面。
对于噪声这里不想深入讨论,感兴趣的可以去看论文或者冯女神的博客https://blog.csdn.net/candycat1992/article/details/50346469。
另外,我们使用的噪声其实是这个噪声:https://www.shadertoy.com/view/4sfGzS
这是Iq大神弄的一个噪声,效率和表现都算很好。
float noise(in float3 x) { float3 p = floor(x); float3 f = frac(x); f = f * f*(3.0 - 2.0*f); float2 uv2 = (p.xy + float2(37.0, 17.0)*p.z) + f.xy; float2 rg = tex2Dlod(_NoiseTex, float4((uv2 + 0.5) / 256.0, 0, 0)).yx; return lerp(rg.x, rg.y, f.z); } float4 map(in float3 p, in float t) { float3 pos = p; //d就是当前坐标距离顶部的差值 pos.y += _cloudRange.z; pos /= _cloudRange.z; float d = -max(0.0, pos.y - _cloudRange.y / _cloudRange.z); float3 q = pos - _Wind.xyz * _Time.y; float f; f = 0.5000*noise(q); q = q * 2.02; f += 0.2500*noise(q); q = q * 2.03; f += 0.1250*noise(q); q = q * 2.01; f += 0.0625*noise(q); //算出的噪声就是我们想要的噪声,然后让d去和噪声相加,模拟当前云的颜色值。 d += _NoiseMultiplier * f; d = saturate(d); float4 res = (float4)d; res.xyz = lerp(_Bright, _Dark, res.x*res.x); return res; }
这些还不够,我们还要分析下基本的算法,看下地平线早期的云实现:
主要思想还是根据raymarching得到云的外形,然后加上光照。
接下来先一步一步实现整个过程,但因为手机上要考虑性能,所以会对完整的云进行大幅度阉割。
//算出垂直fov的一半的弧度 float halfFov_vert_rad = Camera.main.fieldOfView * Mathf.Deg2Rad / 2.0f; //根据tan可以算出当距离为1的时候摄像机的宽度,进行atan就可以得到水平的弧度,依然是一半 float halfFov_horiz_rad = Mathf.Atan(Mathf.Tan(halfFov_vert_rad) * Camera.main.aspect); //同样,在shader里也是进行如此的计算 void computeCamera(in float2 screenPos, out float3 ro, out float3 rd) { //这是水平tan float tanFovH = _TanFov; //这是垂直tan float tanFovV = _TanFov * _ScreenParams.y / _ScreenParams.x; float3 forward = UNITY_MATRIX_V[2].xyz; float3 right = UNITY_MATRIX_V[0].xyz; float3 up = UNITY_MATRIX_V[1].xyz; //出发点就是摄像机的位置 ro = _WorldSpaceCameraPos; //方向根据屏幕的y坐标,往垂直方向偏移,根据x坐标,往水平方向偏移,看如果y坐标或者x坐标满值,刚好就是摄像机的视角边缘线 rd = normalize(forward + screenPos.y * tanFovV * up + screenPos.x * tanFovH * right); } float4 RayMarch(in float3 ro, in float3 rd, in float zbuf) { float4 sum = (float4)0; float dt = 0.1; float t = dt; //这个是根据方向算出完全朝上的部分 float upStep = dot(rd, float3(0, 1, 0)); bool rayUp = upStep > 0; float angleMultiplier = 1; for (int i = 0; i < StepCount; i++) { //摄像机的深度图-0.1 float distToSurf = zbuf - t; //从ro出发的y增加上rd的y,比例是t float rayPosY = ro.y + t * rd.y; /* Calculate the cutoff planes for the top and bottom. Involves some hardcoding for our particular case. */ float topCutoff = (_CloudVerticalRange.y + _CloudGranularity * max(1., _ParallaxQuotient) + .06*t + max(0, ro.y)) - rayPosY; float botCutoff = rayPosY - (_CloudVerticalRange.x - _CloudGranularity * max(1., _ParallaxQuotient) - t / .06 + min(0, ro.y)); if (distToSurf <= 0.001 || (rayUp && topCutoff < 0) || (!rayUp && botCutoff < 0)) break; // Fade out the clouds near the max z distance float wt; if (zbuf < _ProjectionParams.z - 10) wt = (distToSurf >= dt) ? 1. : distToSurf / dt; else wt = distToSurf / zbuf; RaymarchStep(ro + t * rd, dt, wt, sum, t); t += max(dt, _CloudStepMultiplier*t*0.0011); } return saturate(sum); }
实现以上所有的部分你可以得到一个普通的固定视角看上去还不错的云。类似于这样:
然而,如果你移动你摄像机的位置,你会发现这个云会出现各种异常。于是我们要一步一步解决问题。
首先是计算量的浪费问题,我们建立的云层模型是水平的,有顶部和底部组成,摄像机出发的射线,其实是经过顶部和底部的部分才有效果,那么其实我们可以直接从摄像机的顶部交点或者底部交点开始运算。如果位于摄像机里面,那么就是从摄像机位置开始运算。
float t = 0; if (rd.y < 0) { t = (_cloudTop - ro.y) / rd.y; t = max(0, t); } else { t = (_cloudBottom - ro.y) / rd.y; t = max(0, t); }
这样子就可以解决第一个问题。
由于正常情况下云层一般较高,移动摄像机位置引起的变化量容易过大,导致一旦开始移动,采样贴图的坐标也迅速移动,导致云层瞬间变化。然而我们知道 ,摄像机的那点移动对于云来说不算什么,于是我增加了一个缩放系数,因为云层基本在几千米左右,我就把这个系数定位1000,然后根据和云层的距离再做一个基本的非线性关系。
float times = 1000; if (ro.y > _cloudTop) { times *= pow(max(0, (2 * _cloudTop - ro.y) / _cloudTop), 1); } else { times *= pow(max(0, _cloudBottom - ro.y) / _cloudBottom, 0.1); times = max(times, 1); } pos = ro / times + rd * (t - abs(_cloudOffset / rd.y)) + offset;
可以看到,我都会根据顶部还是底部做不同的分支,这其实是我偷懒,可以用直线和线段求教简化这段代码,但现在就先这样,能实现功能优先。最后还有一个_cloudOffset和offset,我们后面再讲解。
接下来是边缘过度问题,云层的边缘超过顶部和底部会直接不计算,那么会导致一个难看的切边,于是我要根据顶部和底部增加一个透明过度。另外,如果云层很厚,我们将整个噪点分布在全部云层,会导致云层和稀薄,于是需要一个循环处理,我通过一个简单的线性周期函数来实现这个东西。
float offy = p.y - _cloudBottom; float topOff = _cloudTop - p.y; topOff = clamp(topOff, 0, _cloudEdge) / _cloudEdge; offy = clamp(offy, 0, _cloudEdge) / _cloudEdge; float offy1 = _cloudPadding * abs(frac(offy * _cloudLayers) - 0.5) - 1.5; float den = clamp((_cloudCut - offy1 + _smooth * f) * topOff * offy, 0, 1); return den
到这里,理论上应该可以了,但实际操作中发现离云很远的时候,云的噪点很厉害。想象一下在现实中,我们很远看云的时候,云的那些细节会逐渐变成较大块的纯色,边缘还是有细节。而我们根据位置进行那么远的采样,必然会导致都是噪点。我思来想去,觉得如果一致能维持一个较近的渲染效果,根据距离做一丢丢的变化,那就好了。于是我增加了_cloudOffset这个变量,会根据摄像机的位置和云的位置动态变化,保证云的渲染效果一直以较近距离观察为主。这样虽然在人靠近云的感受不太自然,但整体的感觉还可以接受。
最后,是光照。
这部分我我没有太多想法,而是直接使用了iq大佬的基本思想,云的边缘会变量。通过往光线方向采样,得到新的位置的云的密度,如果密度变小,说明越靠近云的边缘,就更亮。
具体实现的时候依然根据云的上层和下层做了区别,因为如果一个方向变量,那么另一个方向反而会变暗,所以需要反向一下y轴。
void addSum(float den, float3 pos, float t, inout float4 sum, float3 sunColor, float3 ro, float3 rd) { if (den > 0.01) { float a = 0.6; if (ro.y > _cloudTop) { rd = -rd; a = 0.45; } float dif = clamp((den - map(pos , ro, rd, t, 0.3 * _WorldSpaceLightPos0.xyz)) / a, 0.0, 1.0); float3 lin = float3(0.65, 0.7, 0.75) * 1.4 + _LightColor0 * dif; float4 col = float4(lerp(float3(1, 0.95, 0.8), float3(0.25, 0.3, 0.35), den), den); col.xyz *= lin; col.a *= 0.4; col.rgb *= col.a; sum = sum + col * (1 - sum.a); } }
至此,我们大概实现了这样的云:
视频中还有一个穿越云层的效果,具体可以看视频内容。
最后说下这个云的问题。
这个云还远远没有到可使用的级别,第一,作为后处理特效,最早也只能在非透明物体渲染完成后,也就是云老是叠在非透明物体上。更合理的应该还是用CommandBuffer去做,可以控制渲染的时机。或者用一个面片去替代后处理,但是面片就无法降采样了,性能无法达到手机上的标准。所以还是需要用CommandBuffer改造这个云。
当然也可以自己设置深度缓存,这样子就可以实现云和物体的交融,但是性能当然就更耗了。
即便没有上述这些,这个手机场景demo在vivo x6手机上也只能跑30帧。那个穿越云层的大咖只有10帧。所以,还是需要更好一点的手机才能使用这种方式制作的云。
总而言之,还需要继续优化。
最后是插件地址:
https://www.assetstore.unity3d.com/en/?stay#!/content/133674