写在前面
体积光,这个名称是God Rays的中文翻译,感觉不是很形象。God Rays其实是Crepuscular rays在图形学中的说法,而Crepuscular rays的意思是云隙光、曙光、曙暮辉的意思。在现实生活中,它的样子大概是下面这样:
体积光的翻译大概就是由于这种光可见好像有体积似的。这些光一般是由于强烈的阳光从一些缝隙,如云间缝隙、窗户的缝隙中,透到较暗的环境中所造成的。如果要真实模拟体积光,可能需要很复杂的粒子渲染。但这在移动设备上基本是不可能实现的。
ShadowGun中把体积光归于是雾的一种应用。雾和体积光有很多相似的地方,一个大面积的体积光从视觉上来看和雾很像,它们有一个共性的感性认识,就是其可见度和距离视角的远近有关。因此,ShadowGun用简单网格+Alpha Blending的方法来模拟雾和体积光。由于ShadowGun中体积光是雾的一种应用,因此下面统一称为雾效。
ShadowGun
ShadowGun其实最开始是2011年的一个移动平台的第三人称射击游戏。当然,也是用Unity开发的。当年,由于在画面上的出色表现赢得了很多眼球~更难能可贵的是,在2012的时候,它的开发者放出了示例场景,来让更多的开发者学习如何优化移动平台上的shader。下载地址请戳官方博客。看不懂英文的可以看这篇(写得很不错)。项目里共包含了将近20个优化后的shader。关于使用许可的问题,项目里的shader都是可以免费使用的,而贴图和模型是不可用于商业用途的呦~
虽然ShadowGun的出场时间有点久远了,但很多技术还是可以借鉴滴~而且它现在仍然在更新,并且价格为高昂的¥30,可见其对自信程度。
ShadowGun里包含了几个比较重要的shader,例如非常有名的旗帜飘动的shader,动态效果的天空盒子的shader,环境高光纹理映射等等。
ShadowGun中的雾效
这里的雾效不是指那种真的全局环境都受影响的大雾,而是一种现象:在视角逐渐接近它的时候,视野逐渐清晰。例如对于体积光,从远处看它可能会感觉很亮,但越接近亮度越小,越能看清后面的物体。这种效果可以很好地让玩家感觉到深度的变化。ShadowGun的解决方法是使用一个简单的网格(Fog planes)+透明纹理来模拟。一旦玩家靠近时,通过减淡颜色+使网格顶点移开(需要移开的原因是因为,即使是完全透明的alpha面也会消耗很多渲染时间,而这里的移开一般是把网格收缩变小,减少透明区域)的方法来模拟这个效果。
而如果要使用这个Shader,就需要在三维软件中处理那么Fog planes:
- 顶点的透明度用于决定顶点是否可以移动(透明度为0表示不可移动,1为可移动)
- 顶点法线决定移动的方向
- 然后Shader通过计算与观察者的距离来控制雾面的淡入/淡出。
这种技术非常简单,而且可以用于光射线、光锥等各种透明效果。
在ShadowGun中,有三个shaders使用了这个技术:GodRays,Blinking GodRays和Blinking GodRays Billboarded。
其中GodRays用于模拟体积光。
Blinking GodRays用于各种blingbling的光效,它也是这三个中应用最广的一个shader,包括了光锥的闪烁、水面反光、仪表盘的灯光闪烁(图中的绿色发光部分)、金属表面的反光闪烁、顶棚的阳光闪烁、火焰及火光的闪烁(地面上的火光闪烁和飞船后发射器的火焰)、光雾效果(背景的蓝绿色光雾)等等。
Blinking GodRays Billboarded用于水箱中的blingbling灯光效果(下图中罐体周围的绿色发光部分)。
可以看出来,场景里几乎任何看起来会发光的物体都是靠这种技术模拟的。
GodRays
GodRays是其中最简单、最基本的shader。
代码如下:
Shader "MADFINGER/Transparent/GodRays" { Properties { _MainTex ("Base texture", 2D) = "white" {} _FadeOutDistNear ("Near fadeout dist", float) = 10 _FadeOutDistFar ("Far fadeout dist", float) = 10000 _Multiplier("Multiplier", float) = 1 _ContractionAmount("Near contraction amount", float) = 5 } SubShader { Tags { "Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent" } Blend One One // Blend One OneMinusSrcColor Cull Off Lighting Off ZWrite Off Fog { Color (0,0,0,0) } LOD 100 CGINCLUDE #include "UnityCG.cginc" sampler2D _MainTex; float _FadeOutDistNear; float _FadeOutDistFar; float _Multiplier; float _ContractionAmount; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; fixed4 color : TEXCOORD1; }; v2f vert (appdata_full v) { v2f o; float3 viewPos = mul(UNITY_MATRIX_MV,v.vertex); float dist = length(viewPos); float nfadeout = saturate(dist / _FadeOutDistNear); float ffadeout = 1 - saturate(max(dist - _FadeOutDistFar,0) * 0.2); ffadeout *= ffadeout; nfadeout *= nfadeout; nfadeout *= nfadeout; nfadeout *= ffadeout; float4 vpos = v.vertex; vpos.xyz -= v.normal * saturate(1 - nfadeout) * v.color.a * _ContractionAmount; o.uv = v.texcoord.xy; o.pos = mul(UNITY_MATRIX_MVP, vpos); o.color = nfadeout * v.color * _Multiplier; return o; } ENDCG Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma fragmentoption ARB_precision_hint_fastest fixed4 frag (v2f i) : COLOR { return tex2D (_MainTex, i.uv.xy) * i.color; } ENDCG } } }
可以看出来,frag函数非常简单。其实ShadowGun中很多就是都是通过网格来模拟光照效果,它们的frag函数一般非常简单,而大部分计算都在vert函数中。这是可以理解的,因为逐顶点永远比逐像素的效率更高。vert里负责计算三个部分:一个是顶点位置,一个是纹理坐标,一个是传递给fragment的颜色信息(这里还包含了重要的透明度信息)。frag函数里就可以通过简单的纹理采样和颜色相乘来得到最终的效果。
我们来看最重要的vert函数。这个shader中没有对纹理坐标做什么更改,因此,这个vert函数的关键只有两个部分,一个是顶点位置,一个的颜色信息。
我们先来看颜色的计算过程。vert在输入的顶点颜色的基础(这意味着在建模时就要给顶点赋予合适的体积光颜色)上,对其还乘以了一个乘数_Multiplier和一个衰减值nfadeout。乘数_Multiplier很好理解,就是用于改变亮度而已。关键在于nfadeout。nfadeout是一个范围在(0,1)之间的淡化系数,它用于模拟淡入或淡出效果。和它相关的有两个属性:_FadeOutDistNear和_FadeOutDistFar。玩家由无限远开始接近这个物体的过程中,一开始是远大于_FadeOutDistFar,那么是看不到这个体积光的;然后逐渐接近_FadeOutDistFar后,开始出现淡入效果;如果小于了_FadeOutDistNear,那么就会开始模拟淡出的效果。与其相关的是下面两句:
float nfadeout = saturate(dist / _FadeOutDistNear); float ffadeout = 1 - saturate(max(dist - _FadeOutDistFar,0) * 0.2);
其中dist是在View Space中距离原点的远近,也就是距离视角的远近。nfadeout负责计算“如果小于了_FadeOutDistNear,那么就会开始模拟淡出的效果”这一效果。可以看出,当dist大于_FadeOutDistNear时,总是返回1,从而不会产生任何影响;而一旦小于_FadeOutDistNear后,就会产生一个线性的衰减。ffadeout的计算看起来复杂也难懂一点。我们希望ffadeout的结果是,在dist远大于_FadeOutDistFar时返回0;在dist逐渐接近_FadeOutDistFar时,逐渐从0增加到1;在dist小于_FadeOutDistFar时,返回1。从函数图像来看,其实就是个分段函数。上面的写法只是通过max和saturate函数来实现这种分段的目的,其中0.2是模拟了淡入的速率。下面就是这句计算表达式的函数图像:
对于一般的射灯模拟,_FadeOutDistNear的值都比较大。在计算完nfadeout和ffadeout后,并没有直接相乘,而是各自进行了指数操作。这里感觉是感性的计算,即希望淡入/淡出的速率更快或者更慢等。
下面是顶点位置的计算。与其相关的语句是:
float4 vpos = v.vertex; vpos.xyz -= v.normal * saturate(1 - nfadeout) * v.color.a * _ContractionAmount;
这里的目的是为了在淡出时移开(也可以为收缩)顶点。当nfadeout值越接近0时,表明正在淡出,那么顶点就需要朝着其法线方向的反方向进行收缩。其中,顶点的透明通道决定了这个顶点是否可以移动(这是因为,体积光往往有一边是不可以移动的,想象一下从窗户投进来的光,起点用于在和窗户的衔接处是不会动的)。而_ContractionAmount表示收缩的程度。
剩下的部分就没什么难的了。
Blinking GodRays
Blinking GodRays只更改了vert部分,而且涉及到更多的参数和变量。代码如下:
v2f vert (appdata_full v) { v2f o; float time = _Time.y + _BlinkingTimeOffsScale * v.color.b; float3 viewPos = mul(UNITY_MATRIX_MV,v.vertex); float dist = length(viewPos); float nfadeout = saturate(dist / _FadeOutDistNear); float ffadeout = 1 - saturate(max(dist - _FadeOutDistFar,0) * 0.2); float fracTime = fmod(time,_TimeOnDuration + _TimeOffDuration); float wave = smoothstep(0,_TimeOnDuration * 0.25,fracTime) * (1 - smoothstep(_TimeOnDuration * 0.75,_TimeOnDuration,fracTime)); float noiseTime = time * (6.2831853f / _TimeOnDuration); float noise = sin(noiseTime) * (0.5f * cos(noiseTime * 0.6366f + 56.7272f) + 0.5f); float noiseWave = _NoiseAmount * noise + (1 - _NoiseAmount); float distScale = min(max(dist - _SizeGrowStartDist,0) / _SizeGrowEndDist,1); wave = _NoiseAmount < 0.01f ? wave : noiseWave; distScale = distScale * distScale * _MaxGrowSize * v.color.a; wave += _Bias; ffadeout *= ffadeout; nfadeout *= nfadeout; nfadeout *= nfadeout; nfadeout *= ffadeout; float4 mdlPos = v.vertex; mdlPos.xyz += distScale * v.normal; o.uv = v.texcoord.xy; o.pos = mul(UNITY_MATRIX_MVP, mdlPos); o.color = nfadeout * _Color * _Multiplier * wave; return o; }
同样,这个shader也是只修改了颜色信息和顶点位置。
我们先来看顶点位置。顶点位置的计算如下:
float distScale = min(max(dist - _SizeGrowStartDist,0) / _SizeGrowEndDist,1); distScale = distScale * distScale * _MaxGrowSize * v.color.a; float4 mdlPos = v.vertex; mdlPos.xyz += distScale * v.normal; o.pos = mul(UNITY_MATRIX_MVP, mdlPos);
先来理解为什么要修改顶点位置。这里并不是和上面一样是为了收缩顶点,相反是为了扩大顶点区域。这主要是为了模拟射灯的效果,我们在远离光源的过程中会感觉好像光照范围范围变大了。distScale和上面的ffadeout类似,它同样是一个范围在(0,1)之间的值,有两个参数控制顶点增长的起始和终止位置,_SizeGrowStartDist和_SizeGrowEndDist。在dist小于_SizeGrowStartDist时,distScale返回0;在dist逐渐大于_SizeGrowStartDist时,逐渐从0增大;当dist(实际是dist-_SizeGrowStartDist,但_SizeGrowStartDist通常都很小)大于_SizeGrowEndDist时,返回1,表示已经达到了最大的扩张大小。其中,_MaxGrowSize用于控制扩张的大小,而顶点的透明度决定了该点是否会移动(和上面的类似的)。由于这里是扩张和非收缩,因此移动方向是朝着顶点法线的方向正向移动。
比较复杂的是顶点颜色的计算。
o.color = nfadeout * _Color * _Multiplier * wave
首先,这里没有使用原来的顶点颜色进行计算,而是允许用户在面板中调整_Color参数。这是可以理解的,因为体积光的颜色基本不变,通常都是偏黄的,因此在上一个shader中可以直接使用原来的顶点颜色进行计算,节约空间。而这里的用途很广泛,让用户自定义颜色是更好的选择。上面的nfadeout和_Multiplier与之前的计算无异,不再赘述。复杂的是wave的计算:
float fracTime = fmod(time,_TimeOnDuration + _TimeOffDuration); float wave = smoothstep(0,_TimeOnDuration * 0.25,fracTime) * (1 - smoothstep(_TimeOnDuration * 0.75,_TimeOnDuration,fracTime)); float noiseTime = time * (6.2831853f / _TimeOnDuration); float noise = sin(noiseTime) * (0.5f * cos(noiseTime * 0.6366f + 56.7272f) + 0.5f); float noiseWave = _NoiseAmount * noise + (1 - _NoiseAmount); wave = _NoiseAmount < 0.01f ? wave : noiseWave; wave += _Bias;
我们先来理解要达到的效果。基本的淡出效果已经通过上面的nfadeout实现了,因此这里主要是为了模拟闪烁的效果。闪烁本质上就是一种动画效果,这里给出了两种动画模拟的方式:一种是均匀跳跃的脉冲波模拟,一种是非均匀的噪声模拟。这是用过_NoiseAmount参数实现的,它的面板注释中也说明了这一点,“Noise amount (when zero, pulse wave is used)”,即当噪声很小时,就会使用均匀跳跃的脉冲波模拟,否则就使用_NoiseAmount模拟非均匀的闪烁动画。
我们首先来看如何模拟均匀的脉冲波闪烁。其计算式如下:
float time = _Time.y + _BlinkingTimeOffsScale * v.color.b; float fracTime = fmod(time,_TimeOnDuration + _TimeOffDuration); float wave = smoothstep(0, _TimeOnDuration * 0.25, fracTime) * (1 - smoothstep(_TimeOnDuration * 0.75, _TimeOnDuration, fracTime));
这里面涉及了三个参数:_BlinkingTimeOffsScale,_TimeOnDuration,_TimeOffDuration。我们还是从wave的函数图像出发来看这里面的猫腻(注意其中的参数):
可以看出来_TimeOnDuration和_TimeOffDuration两个参数负责控制脉冲波的高频区域的时间长度和低频区域的时间长度。而_BlinkingTimeOffsScale代码里的说明是,“Blinking time offset scale (seconds)”,从第二幅图像上可以看出来其实就是制定从哪个位置开始模拟脉冲闪烁,需要注意的是,_BlinkingTimeOffsScale的取值范围在(0, _TimeOnDuration + _TimeOffDuratio),如果大于这个范围也会相当于对_TimeOnDuration + _TimeOffDuratio取模。
图像直观了解后,我们再来看代码。fracTime反应了当前处于一个周期中的那个阶段,因此需要使用当前的时间time对整个循环周期_TimeOnDuration + _TimeOffDuratio取模。smoothstep函数将返回一个范围在(0, 1)之间的值,这个值由第三个参数相对于前两个参数的位置来平滑插值决定的。Nvidia文档里是这样给出它的参考代码的:
float smoothstep(float a, float b, float x) { float t = saturate((x - a)/(b - a)); return t*t*(3.0 - (2.0*t)); }
可以看出,当第三个参数小于第一个参数时,结果返回0;大于第二个参数时,结果返回1;否则进行平滑插值。这个函数决定了图像中平滑上升的区域。代码中的系数决定,左右两边平滑上升(下降)区域的长度占整体高频区域的25%。当fracTime完全大于_TimeOnDuration后,就进入低频区,即输出是0。
下面分析非均匀的噪声闪烁模拟。主要代码如下:
float noiseTime = time * (6.2831853f / _TimeOnDuration); float noise = sin(noiseTime) * (0.5f * cos(noiseTime * 0.6366f + 56.7272f) + 0.5f); float noiseWave = _NoiseAmount * noise + (1 - _NoiseAmount);
函数图像如图所示:
从代码看,噪声模拟主要依靠一个正弦函数和余弦函数的乘积来实现。
Blinking GodRays Billboarded
终于到了最后了,真累。Blinking GodRays Billboarded和上一篇有相通的地方,它们都是blingbling的!但区别在于,Billboarded的意思是它总是会面朝着观察者的方向,它的网格其实是一些平板(像广告板一样),但是由于它总是会根据我们的观察方向来随时旋转,让我们感觉它是立体的一样。Billboarded的行为就跟向日葵总是会朝着太阳一样!它在ShadowGun中用于模拟水箱中的灯光效果。
主要代码如下:
v2f vert (appdata_full v) { v2f o; #if 0 // cheap view space billboarding float3 centerOffs = float3(float(0.5).xx - v.color.rg,0) * v.texcoord1.xyy; float3 BBCenter = v.vertex + centerOffs.xyz; float3 viewPos = mul(UNITY_MATRIX_MV,float4(BBCenter,1)) - centerOffs; #else float3 centerOffs = float3(float(0.5).xx - v.color.rg,0) * v.texcoord1.xyy; float3 centerLocal = v.vertex.xyz + centerOffs.xyz; float3 viewerLocal = mul(_World2Object,float4(_WorldSpaceCameraPos,1)); float3 localDir = viewerLocal - centerLocal; localDir[1] = lerp(0,localDir[1],_VerticalBillboarding); float localDirLength=length(localDir); float3 rightLocal; float3 upLocal; CalcOrthonormalBasis(localDir / localDirLength,rightLocal,upLocal); float distScale = CalcDistScale(localDirLength) * v.color.a; float3 BBNormal = rightLocal * v.normal.x + upLocal * v.normal.y; float3 BBLocalPos = centerLocal - (rightLocal * centerOffs.x + upLocal * centerOffs.y) + BBNormal * distScale; BBLocalPos += _ViewerOffset * localDir; #endif float time = _Time.y + _BlinkingTimeOffsScale * v.color.b; float fracTime = fmod(time,_TimeOnDuration + _TimeOffDuration); float wave = smoothstep(0,_TimeOnDuration * 0.25,fracTime) * (1 - smoothstep(_TimeOnDuration * 0.75,_TimeOnDuration,fracTime)); float noiseTime = time * (6.2831853f / _TimeOnDuration); float noise = sin(noiseTime) * (0.5f * cos(noiseTime * 0.6366f + 56.7272f) + 0.5f); float noiseWave = _NoiseAmount * noise + (1 - _NoiseAmount); wave = _NoiseAmount < 0.01f ? wave : noiseWave; wave += _Bias; o.uv = v.texcoord.xy; o.pos = mul(UNITY_MATRIX_MVP, float4(BBLocalPos,1)); o.color = CalcFadeOutFactor(localDirLength) * _Color * _Multiplier * wave; return o; }
虽然代码好像多了很多,但我们只要从顶点位置和颜色两方面入手就可以理清它的思路。
我们先来看顶点颜色的计算。这一部分和上一个shader几乎完全一样,稍有不同的是,它把计算淡入淡出的工作封装到了一个函数中CalcFadeOutFactor。对于上一个shader来说,CalcFadeOutFactor函数的输入是在View Space中顶点的距离,但在这里不可以直接使用顶点的原始位置v.vertex,而是修改后的网格中心位置距离localDirLength。
下面是最关键的顶点位置的计算。它的相关代码如下:
float3 centerOffs = float3(float(0.5).xx - v.color.rg,0) * v.texcoord1.xyy; float3 centerLocal = v.vertex.xyz + centerOffs.xyz; float3 viewerLocal = mul(_World2Object,float4(_WorldSpaceCameraPos,1)); float3 localDir = viewerLocal - centerLocal; localDir[1] = lerp(0,localDir[1],_VerticalBillboarding); float localDirLength=length(localDir); float3 rightLocal; float3 upLocal; CalcOrthonormalBasis(localDir / localDirLength,rightLocal,upLocal); float distScale = CalcDistScale(localDirLength) * v.color.a; float3 BBNormal = rightLocal * v.normal.x + upLocal * v.normal.y; float3 BBLocalPos = centerLocal - (rightLocal * centerOffs.x + upLocal * centerOffs.y) + BBNormal * distScale; BBLocalPos += _ViewerOffset * localDir; #endif o.pos = mul(UNITY_MATRIX_MVP, float4(BBLocalPos,1));
其中需要注意的地方是,上面使用的坐标都是转换到Object Space下的结果。上述代码首先计算在Object Space下、网格中心到观察点的方向向量localDir。有时,我们并不希望让平板完全垂直与视角的观察方向,而是仅想得到在XZ平面上的方向(想象一个只可以左右摆头,但不可以向上仰头或向下低头的向日葵),因此,这里可以使用_VerticalBillboarding参数对这种垂直程度进行调整,_VerticalBillboarding为0表示完全舍弃Y方向上的信息,仅可以左右摆头;而_VerticalBillboarding为1则表示平板要完全垂直与观察视角的方向。对于水箱这种静止的对象,一般_VerticalBillboarding为0。然后将其正则化后的结果作为CalcOrthonormalBasis的输入,输出的是该网格中心点面对视角的右手方向rightLocal和正上方向upLocal。这两个方向决定了该shader像向日葵一样的行为。得到当前的右手方向和正上方向后,distScale和之前的shader一样,同样是根据距离观察点的距离来计算顶点的扩张程度的。BBNormal则是为了更新旋转后顶点的法线方向,它的计算简单明了,就是使用新的右手/正上方向重新定义法线,这里z方向是不需要考虑的,应该它就是个平板。BBLocalPos一行,首先使用新的右手/正上方向还原该角度下该顶点的对应位置,然后再使用新的法线方向BBNormal和distScale进行扩张。最后,使用参数_ViewerOffset对BBLocalPos进行最后的偏移。_ViewerOffset表示将平板像视角方向进行偏移的量,一般设为0即可。
写在最后
这篇有点长,数学公式也很多,我尽量用函数图像来解释了。这里总结一下上面的各种技术:
- 首先这三篇shader都是为了模拟“伪光源”,使用的技术就是简单网格+透明纹理。它们的frag函数都非常简单,仅仅是根据vert的输出进行纹理采样和颜色乘积。因此,它们的计算重点都在vert函数中。而vert函数也很相似,都是对顶点位置和顶点颜色进行了相应的修改。
- 这种技术效率比较高,但要预处理网格的顶点颜色、透明度等值。例如,在对顶点进行收缩和扩张时,需要使用顶点颜色的透明度来决定该点是否可以移动;在最后一篇计算网格中心点的位置时,每个顶点相对于网格中心点的偏移也提前存储在了顶点的颜色和纹理坐标中。
- GodRays是其中简单基础的shader,它的效果就是使用一张透明纹理来模拟光照,并根据和视角的距离进行淡入淡出。
- Blinking GodRays是其中应用最广的shader,它的效果除了上面的淡入淡出外,还加上了闪烁的效果。闪烁的波形可以选择均匀的脉冲波,或者是有噪声的波形。
- Blinking GodRays Billboarded是其中最复杂的shader,它的效果除了淡入淡出+闪烁外,还可以根据视角方向、实时让网格面向视角,就像向日葵总是会面朝太阳一样。
- 下面对主要参数进行说明:
_MainTex:用于模拟光照的透明纹理。
_FadeOutDistNear:小于这个距离时,会出现淡出效果。在GodRays中,淡出的同时还会收缩顶点。
_FadeOutDistFar:大于这个距离时,会出现淡出效果。在GodRays中,淡出的同时还会收缩顶点。
_Multiplier:光照颜色的乘数,可以用来调亮/调暗最后的模拟光照。
_Bias:模拟闪烁时,波形的偏移,可以理解成把波形图像向Y方向的移动量。
_TimeOnDuration:模拟闪烁时,波形中高频区域的长度,可以理解为闪烁时亮着的时间。
_TimeOffDuration:模拟闪烁时,波形中低频区域的长度,可以理解为闪烁时暗着的时间。
_BlinkingTimeOffsScale:模拟闪烁时,指定闪烁在波形中的开始位置。
_SizeGrowStartDist:大于这个距离时,会开始对顶点进行扩展,即从0开始增长。
_SizeGrowEndDist:达到这个距离时,扩张达到最大程度,即扩展程度为1。
_MaxGrowSize:扩张的最大大小。
_NoiseAmount:模拟闪烁时,噪声的程度,用于混合均匀的脉冲波和噪声波。
_VerticalBillboarding:在Blinking GodRays Billboarded中,平板的垂直程度,返回为(0, 1),0表示不需要垂直与视角方向,仅仅在XZ平面旋转。
_ViewerOffset:在Blinking GodRays Billboarded中,将网格向视角方向移动的偏移量。
_Color:用于改变光照颜色。
写这一篇心好累。。。