尝试实现了一个适用于手游的体积云,可以像飞机一样穿越云层,而且不用手工放置:
(视频有音效,建议选择超清)
手游体积云,可穿越,程序化生成
单个云的形态:(视频22秒,无声,建议选择超清)
手游体积云,可穿越,程序化生成
体积云可以简单地用几个相互穿插的透明Quad(方片)来实现,但是overdraw的问题也会随之而来。在我们优化性能、解决overdraw时,一定要考虑到移动平台GPU的特点:带宽低、对数据精度严格。
基本的实现比较简单直观,我会在第1节快速讲完。今天的主角是性能优化,会在第2节详细介绍。然后我想赋予云更多的动态,还有程序化生成,这些都会在第3节讲到。关于“穿越”的一些细节在第4节。
我经验尚浅,有些结论太过绝对与片面,本文仅是我个人粗浅的见解,以抛砖引玉,如有疏漏希望大家多多加以指正,十分感谢。
1.基本实现
传统的实现云的方式是Ray-Marching。但是与PC或主机不同的是,Ray-Marching对手机来说计算量太大了。所以我们只能另寻出路。
“The Witness”这个游戏里的云,就是简单地把几个Quad穿插在一起,我用抓帧工具把模型抓了出来:
在每个Quad上画上相同的透明纹理:
然后这一堆Quad就变成了这样:
我们可以称它为“云单元”。如果好几个云单元紧紧放在一起,就得到了:
到这一步,云仍然看起来很粗糙,后面“The Witness”也还有更多的步骤去改进它。但是与“The Witness”不同的是,我是在移动平台上做实现,这时候帧率变得非常低。在profile之后,我发现我不得不先解决overdraw的问题,在那之后才能去做更多的效果。
2.性能优化历险记
如果场景中没有透明物体,fragment着色阶段的工作负荷是被屏幕分辨率限制的,fragment阶段的工作量是和屏幕分辨率成比例的。透明物体带来了overdraw,fragment着色的负荷一下子变得不可控起来。Overdraw意味着好几个fragment被draw到同一个像素上去了,于是fragment阶段就容易形成渲染瓶颈。
要战胜overdraw,有两个办法:一个是少draw一些fragment,另一个是减轻每个fragment着色的工作负荷。
①少draw一些fragment
现在用的这个mesh固然是由方片组成的,但是实际上画上去的内容(纹理)却是个圆形,那么方片的四个角明显是被浪费了。这些角落是完全透明的,但是仍然要消耗渲染性能。我们可以把角落的部分削掉,留下一个圆形的片。
但是有人可能就迷惑了,虽然这样少draw了一些fragment,但是要draw更多的顶点啊!?这样做真的值吗?当然值了。因为根据渲染流水线的原理,现在瓶颈位于fragment着色的阶段。就算是让顶点着色的工作负荷加重一点,整体的渲染速度仍然是由fragment着色来决定的。另外,我用的不是一个完美的圆形,我用的是十二边形,没有增加太多顶点。
②减轻每个fragment着色的工作负荷
在移动设备上,GPU和显存之间的带宽是很受限的,所以GPU从显存里取纹理数据是很消耗性能的。我决定做一次大胆的尝试:把纹理采样替换成程序化生成的噪声,也就是说Perlin噪声是在fragment shader里实时计算出来的。这种做法听起来是有点激进,但是profile之后,我发现这种做法把渲染时间减少了三分之一,甚至外观也更真实了。
然而事情到这里还没完。
PC和主机平台会在内部把fixed和其他低精度类型转换成float,但是移动设备会区分不同精度的数据类型。它们区分是因为精度的确会影响性能的。所以被把生成噪声的函数里所有的float都改成了fixed,这个时候的渲染时间刚好变为了最开始的一半。
3.更丰富的动态,以及程序化生成
云的动态极其复杂,但是可以总结为三类:
第一,云作为一个整体移动。
第二,云会蠕动、拉伸、甚至扭曲。这都属于它自身形状的改变,我们可以通过移动顶点来模拟。
(Cloud.shader)
// move the cloud around as a whole
o.vertex.xy += sin(_Time.y * _FlowSpeed) * 3.0;
// move every vertex around individually, and make their movement different from each other, which makes the cloud look like wriggling
o.vertex.xyz += (sin(_Time.w * _WriggleSpeed + (v.vertex.x + v.vertex.y + v.vertex.z) * _WriggleVertexDivergence) + 1.0) * _WriggleMagnitude;
第三,水蒸气在云的内部流动,这一点通过流动UV来体现。另外,这里的"UV"不是用来采样纹理的,而是程序化生成噪声所需要的输入参数。
// generate perlin noise in real-time,
fixed perlinNoise = (cnoise(texcoord * 4.0 + _Time.x * 10.0) + 1) * 0.5;
云复杂的动态(无声)
接下来自动地把“云单元”放置在一起,这里我还是用Perlin噪声来实现。我在一块方形区域生成云单元,根据水平位置坐标来生成Perlin噪声。这个噪声值会被阈值,通过阈值的地方可以放置云单元,没通过的地方不可以。但是这个时候的云看起来太“整齐”了,整齐划一地放在网格上,就像一个士兵方阵。那么怎么把它们搞得“乱糟糟”一点呢?所以我在位置、缩放和旋转上都加了随机值。我尤其希望他们的垂直位置(y)形成差异,这样云就看起来厚一点。至于缩放,水平方向(xz)的缩放要比垂直方向的缩放要拉伸得更多,为的是让云看起来更平坦。
(GenerateClouds.cs)
bool OkToPlace (int x, int y)
{
float xCoord = (float)x / oneSideAmount * shapeScale;
float yCoord = (float)y / oneSideAmount * shapeScale;
float sample = Mathf.PerlinNoise(xCoord, yCoord);
// determines what proportion of the sky is covered by clouds if (sample < CoverageRate)
return true;
return false;
}
void Generate()
{
oneSideAmount = totalRange * density;
Vector3 position;
Vector3 scale;
Random.InitState(42);
for (int x = 0; x < oneSideAmount; x++)
{
for (int y = 0; y < oneSideAmount; y++)
{
if (OkToPlace(x, y))
{
Transform cloud = Instantiate(cloudPrefab);
position.x = ((float)x / (float)oneSideAmount) * (totalRange * 8) - (totalRange * 4);
position.z = ((float)y / (float)oneSideAmount) * (totalRange * 8) - (totalRange * 4);
position.y = ((float)Random.Range(-255, 256) / 512f) * thickness + height;
float xRand = ((float)Random.Range(-127, 128) / 512f);
float zRand = ((float)Random.Range(-127, 128) / 512f);
float yRand = Mathf.Min(xRand, zRand);//make the cloud look more flat float scaleRand = ((float)Random.Range(-127, 128) / 512f);
float currentScale = cloudScale * (scaleRand + 1f);
scale.x = currentScale * (xRand + 1f);
scale.z = currentScale * (zRand + 1f);
scale.y = currentScale * (yRand + 1f) * 0.8f;// * 0.8f is to make the cloud look more flat cloud.localPosition = position;
cloud.localScale = scale;
cloud.localRotation = Quaternion.Euler(0, (float)Random.Range(0, 180), 0);
// join the newly created cloud into the CloudGroup parent object cloud.SetParent(transform, false);
// no need for clouds to cast or receive shadows cloud.GetComponent<Renderer>().shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
cloud.GetComponent<Renderer>().receiveShadows = false;
}
}
}
}
这个时候从上朝下看是这样的:
4.还有几个重要的小Trick
我们希望的是,这个云在中心处紧实,在边缘虚化。所以我们用半径来让噪声值衰减:(Cloud.shader)
fixed procedualTex(fixed2 texcoord)
{
// holistic transparency, set to 0.2 to make clouds look less stuffy when looked from outside
const fixed trasparency = 0.20;
// to make texcoords vary from the range of [-0.5, 0.5]
fixed x = texcoord.x - 0.5;
fixed y = texcoord.y - 0.5;
// make this piece of cloud dense in the middle and fade out towards the edge.
// By writting (x * x + y * y) I wanted to represent the radius, but we don't have to be so rigorous so I left out the square-root,
// and doing square-root is computationally intensive for shaders, by the way.
fixed attenuation = max(((0.25 - (x * x + y * y)) * 4.0 * trasparency), 0.0);
// generate perlin noise in real-time,
fixed perlinNoise = (cnoise(texcoord * 4.0 + _Time.x * 10.0) + 1) * 0.5;
//fixed perlinNoise = (cnoise(texcoord * 4.0 + _Time.y) + 1) * 0.5;
return perlinNoise * attenuation + attenuation * attenuation;
}
从侧边看一片mesh它会看起来像是一个片,这样云有很多尖刺。最直接的解决办法就是,如果法线没有朝向我们,就让云淡去。这个解决方案来自王阳
(Cloud.shader)
fixed4 frag (v2f i) : SV_Target
{
// when this piece of cloud is looked from aside, it looks like a sharp piece, which is not supposed to exist in a real cloud
// we can let this piece of cloud fade out if it's normal is perpendicular to our view direction
const float fade = 0.5;
fixed3 worldNormal = normalize(i.worldNormal);
float3 worldViewDir = normalize(i.worldViewDir);
float rim = abs(dot(worldViewDir, worldNormal));
fixed tmp = step(fade,rim);
// when it approaches the camera's near clip plane, let it fade out,
// or the meshes will be sharply cut by the near clip plane
const half cutFade = 20.0;
half viewDistance = length(i.worldViewDir);
fixed cut = smoothstep(_NearClipPlane, _NearClipPlane + cutFade, viewDistance);
fixed alpha = procedualTex(i.texcoord);
// if the tmp approaches to 1.0, we output alpha, and if the tmp approaches 0.0, we output another term
// by doing this we can avoid IF operation in shader, which stalls the GPU a lot
alpha = alpha * tmp + (1.0 - tmp) * lerp(0.0, alpha, ((max(0, (rim - 0.1))) / (fade - 0.1)));
alpha *= cut;
return fixed4(1.0, 1.0, 1.0, alpha);
}
还有一个tip就是记得打开GPU instancing来减少draw-call,因为我们在哪里都用的是同一个mesh。
当穿越云层的时候
我注意到当摄像机埋在云里面的时候,会看到一些尖锐的片状物:
这是因为,mesh的面和摄像机的近裁剪平面相交时,mesh被硬生生截断了。所以我又在shader里用了个小trick,让云在接近摄像机的时候自动淡去。(代码就是上边的)
5.在低端手机上实测
性能优化的最终目的是兼容低端市场,扩大用户总数。所以我拿来一个低端手机:17年上市的红米Note4X来profile,能跑到40帧左右:
源码与示例工程:
https://github.com/MarcusXie3D/MobileVolumetricCloud
参考:
游戏“The Witness”:
http://www.artofluis.com/3d-work/the-art-of-the-witness/clouds/
程序化噪声的shader库:https://github.com/keijiro/NoiseShader
王阳大神的云:王阳:关于风格化云渲染的一些尝试
声明:发布此文是出于传递更多知识以供交流学习之目的。若有来源标注错误或侵犯了您的合法权益,请作者持权属证明与我们联系,我们将及时更正、删除,谢谢。
作者:Marcus Xie
来源:https://zhuanlan.zhihu.com/p/120800393
More:【微信公众号】 u3dnotes