终于实现出美术同事想要的这个效果了:
下面就来讲述我写这个shader的思路。
首先需要一张魔法阵的底图:
用一个平面(Plane)做模型,将底图贴在模型上:
代码如下:
Shader "Inception-Fx/FlowInsideOut" {
Properties {
_MainTex ("Base (RGB)", 2D) = "white" {}
}
SubShader {
Tags {"Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent"}
Cull Off
Lighting Off
ZWrite Off
Fog { Mode Off }
Blend One One
Pass {
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
sampler2D _MainTex;
struct appdata_t {
float4 vertex : POSITION;
half2 texcoord : TEXCOORD0;
};
struct v2f {
float4 pos : POSITION;
half2 mainTex : TEXCOORD0;
};
v2f vert (appdata_t v)
{
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
o.mainTex = v.texcoord;
return o;
}
fixed4 frag (v2f i) : COLOR
{
fixed4 baseColor = tex2D(_MainTex, i.mainTex);
return baseColor;
}
ENDCG
}
}
FallBack "Diffuse"
}
这是一个非常普通的半透shader。如果对此理解有困难的话,需要先读一读Unity shader的相关文档,补充一下shader的基础知识。不过这里有几点需要提及的是:
Cull Off 关闭裁剪,因为我们需要Plane的背面面向摄像机时,我们也能看到这个流光效果。
Lighting Off 关闭光照。我们不希望这个魔法阵受到光照的影响。
ZWrite Off 像素深度不写入Z缓冲区。一般半透shader都要加上这句,不然离摄像机更远的被Plane挡住的模型可能会直接显示不出来,半透效果也就失去了本身的意义。
Fog { Mode Off } 关闭雾效,不然如果场景开启了雾效,这张图就会变成下面的样子,整个Plane的轮廓都被显示出来了:
Blend One One 为了节省内存,贴图采用了ETC压缩格式,没有alpha通道,所以为了呈现半透的效果,Blend就要用叠加(additive blending)而不是alpha混合(alpha blending)的方式。
接下来就要考虑如何做出流光的效果了。这也是难点。整个shader的复杂度就在这里。
我们可以从Vetex & Fragment Shader的逻辑开始考虑。Vertex & Fragment Shader的流程大体分为两步:第一步是Vertex Shader,也就是上文代码中的vert函数,作用是计算模型上每个顶点的信息——这里包括顶点在屏幕上的投影坐标和uv;第二步是Fragment Shader,也可以简单地理解为Pixel Shader,也就是上文代码中的frag函数,作用是最终决定模型的每个像素的着色。上文代码的frag函数只是根据uv对_MainTex贴图进行采样,显示出贴图上对应的像素。要实现出精细的流光效果,就要在frag函数中根据像素离圆心的距离,判断当前像素是否在光带中。
假设当前像素离圆心的距离是r,又假设某一时间点上光带的半径是flowRadiusMin到flowRadiusMax。那么若r < flowRadiusMin或r > flowRadiusMax,当前像素就不在光带中,像素颜色就如代码1一样,只要对贴图进行采样即可。如果flowRadiusMin <= r <= flowRadiusMax,那当前像素就在光带中,像素颜色就应该是光带和底图“合成”的颜色。随着时间的推移,flowRadiusMin和flowRadiusMax会周而复始地从小增大,但flowRadiusMax - flowRadiusMin,即光带的宽度始终保持不变。假设光带的宽度是_FlowWidth(可以作为shader参数开放给美术调节),那么在一个时间周期中,一开始flowRadiusMax为0,flowRadiusMin为-_FlowWidth,整个魔法阵上还没有光带;然后flowRadiusMin和flowRadiusMax匀速地变大,光带开始从魔法阵圆心向外流动;到周期最后,flowRadiusMin增大到魔法阵的半径——设为radiusMax,flowRadiusMax则为radiusMax + _FlowWidth,光带从魔法阵上消失。
接下来要确定圆心的坐标和半径的长度单位。其实这两者可以归结为一个问题,就是用什么做坐标系。我曾经考虑过用顶点坐标,也就是模型自身的局部坐标系,但后来发现顶点坐标的值会收到Drall Call合批的影响(见http://blog.csdn.net/zzxiang1985/article/details/50502624),而且顶点坐标的单位也很难摸索出来。相对而言,uv坐标的分布规律可控。在Plane中,圆心就位于uv坐标(0.5, 0.5)处。魔法阵半径radiusMax可以定为0.5,即圆心到Plane的四条边的距离:
float radiusMax = 0.5;
_FlowWidth的单位也就是以uv坐标系的长度单位。
在代码1中,我们已经在vert函数中将底图的uv赋值给返回值的mainTex成员,并将其传给frag函数。因此在frag函数中,我们就可以求出当前像素离到圆心的距离r:
float2 center = float2(0.5, 0.5);
float r = distance(i.mainTex, center);
知道了当前像素到圆心的距离,还要知道当前时刻的光带半径——flowRadiusMin和flowRadiusMax,我们才能得出当前像素的颜色。假设光带从圆心开始出现,一直流动到魔法阵边缘,从魔法阵上消失的时间周期是_Period(可以作为shader参数开放给美术调节)。那么当前时刻的光带半径则是
float flowRadiusMax = fmod(_Time.y, _Period) / _Period * (radiusMax + _FlowWidth);
float flowRadiusMin = flowRadiusMax - _FlowWidth;
其中fmod是对浮点数取余的Unity shader自带函数,_Time是Unity shader自带的游戏时间变量。
现在可以求当前像素的颜色了。如果flowRadiusMin <= r && r < flowRadiusMax,那么像素颜色就是光带与底图合成的样色。假设光带本身的颜色(即未与底图合成的颜色)为_FlowColor(作为shader参数开放给美术调节),那么当前像素的颜色就是
float finalColor = baseColor + _FlowColor * baseColor;
======================================================================================
PS: 这个地方让我研究了一阵子。我曾考虑过finalColor = baseColor + _FlowColor,但出来的结果是这样(_FlowColor为白色):
这个效果显然是错误的,光带的颜色完全没有根据魔法阵底图的颜色、镂空和半透情况而变化。
于是又在想会不会是finalColor = baseColor * _FlowColor。这显然就更不对了。这种情况下当_FlowColor为白色时,finalColor还是和baseColor一样。就算_FlowColor是别的颜色比如红色,看上去也没有光的感觉,反而像是魔法阵被啃了一圈:
======================================================================================
如果r < flowRadiusMin或r > flowRadiusMax,那么finalColor就仍是baseColor。综上所述,finalColor的计算代码就是:
float finalColor;
if (flowRadiusMin <= r && r < flowRadiusMax)
{
finalColor = baseColor + _FlowColor * baseColor;
}
else
{
finalColor = baseColor;
}
float isInFlow = step(flowRadiusMin, r) - step(flowRadiusMax, r);
fixed4 finalColor = baseColor + isInFlow * _FlowColor * baseColor;
Shader "Inception-Fx/FlowInsideOut" {
Properties {
_MainTex ("Base (RGB)", 2D) = "white" {}
_FlowColor ("Flow Color", Color) = (1, 1, 1, 1)
_Period ("Period (Seconds)", float) = 1
_FlowWidth ("Flow Width", Range(0, 1)) = 0.1
}
SubShader {
Tags {"Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent"}
Cull Off
Lighting Off
ZWrite Off
Fog { Mode Off }
Blend One One
Pass {
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
sampler2D _MainTex;
fixed4 _FlowColor;
float _Period;
float _FlowWidth;
struct appdata_t {
float4 vertex : POSITION;
half2 texcoord : TEXCOORD0;
};
struct v2f {
float4 pos : POSITION;
half2 mainTex : TEXCOORD0;
};
v2f vert (appdata_t v)
{
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
o.mainTex = v.texcoord;
return o;
}
fixed4 frag (v2f i) : COLOR
{
fixed4 baseColor = tex2D(_MainTex, i.mainTex);
float2 center = float2(0.5, 0.5);
float r = distance(i.mainTex, center);
float radiusMax = 0.5;
float flowRadiusMax = fmod(_Time.y, _Period) / _Period * (radiusMax + _FlowWidth);
float flowRadiusMin = flowRadiusMax - _FlowWidth;
float isInFlow = step(flowRadiusMin, r) - step(flowRadiusMax, r);
fixed4 finalColor = baseColor + isInFlow * _FlowColor * baseColor;
return finalColor;
}
ENDCG
}
}
FallBack "Diffuse"
}
有没有发现这和文章最开头展示的效果还是有些不同?文章开头的动画中的流光显得更自然一些,因为光带的边缘很柔和,魔法阵上的每个像素从不发光到发光的过程很平滑;而上面这个动画中,光带的边缘非常清楚,魔法阵上的每个像素从不发光到发光是突然发生的,没有一个渐变的过程。
要解决这个问题,可以让美术多准备一张N x 1大小的灰度贴图(下图是128 x 4,也是等效的)。这张贴图可以理解为光带从内环到外环的alpha分布,两端是黑色,往中间渐渐过渡到白色:
我们需要为该贴图多添加一个shader参数:
_FlowTex ("Flow Texture (RGB)", 2D) = "black" {}
在frag函数中用该贴图的颜色乘以_FlowColor就行了。不过怎么知道当前像素应该对应_FlowTex上的哪个点呢?还是要根据r,flowRadiusMin和flowRadiusMax算出来:
float2 flowTexUV = float2((r - flowRadiusMin) / (flowRadiusMax - flowRadiusMin), 0);
于是最终像素颜色finalColor的计算变为:
fixed4 finalColor = baseColor + isInFlow * tex2D(_FlowTex, flowTexUV) * _FlowColor * baseColor;
这下效果就自然多了:
到这里,魔法阵的效果基本上就做好了。
接下来还可以对shader做些改进。
比如我们可以为shader添加一个_InvertDirection参数。这个参数本质是一个布尔变量,在编辑器中显示成一个CheckBox。
[Toggle] _InvertDirection ("Invert Direction?", float) = 0
我们希望美术将这个参数勾上后,即_InvertDirection为1的情况下,流光的方向就会反过来,从魔法阵边缘流向魔法阵圆心。因此flowRadiusMax的计算方式修改为:
float timeProgress = fmod(_Time.y, _Period) / _Period;
float flowProgress = _InvertDirection * (1 - timeProgress) + (1 - _InvertDirection) * timeProgress;
float flowRadiusMax = flowProgress * (radiusMax + _FlowWidth);
flowRadiusMin的计算不变,还是flowRadiusMax - _FlowWidth。
如果想要调节流光的亮度,可以给shader多添加一个_FlowHighlight浮点参数:
_FlowHighlight ("Flow Highlight", float) = 1
用它乘以光带颜色。于是finalColor变为:
fixed4 finalColor = baseColor + isInFlow * _FlowHighlight * tex2D(_FlowTex, flowTexUV) * _FlowColor * baseColor;
这是_FlowHighlight为3的效果:
这和本文最开头的效果已经完全一致了,除了流光的周期和颜色有点差别。文章最开头的效果动画中,光带的颜色会随着光带半径增大而变化。这一点可以通过类似_FlowTex的思路来实现:将_FlowColor改为一张贴图,用来记录从圆心到魔法阵边缘的颜色。不过我们在项目中也不想为此再多维护一张贴图了,所以就换了一种效果没那么灵活但比较简单仍可以让光带变色的方法,就是将_FlowColor换成两个颜色参数:
_FlowColorAtCenter ("Flow Color At Center", Color) = (1, 1, 1, 1)
_FlowColorAtEdge ("Flow Color At Edge", Color) = (1, 1, 1, 1)
FlowColorAtCenter是位于圆心的光带颜色,_FlowColorAtEdge是位于圆周上的光带颜色。光带位于两者之间时,光带颜色就是这两个颜色之间的插值:
fixed4 flowColor = _FlowColorAtCenter + flowProgress * (_FlowColorAtEdge - _FlowColorAtCenter);
另外,应美术同事的需求,shader还添加了一个_AllAlpha参数,用来控制整个plane的半透。美术想用Animation配合这个参数做出闪烁效果:
_AllAlpha ("All Alpha", Range(0, 1)) = 1
...
finalColor *= _AllAlpha;
还有,美术希望底图的Tiling和Offset能起作用,这样将shader应用在其它地方时,就可以通过底图的重复和偏移方便地做出一些星空的效果。因此vert函数中的mainTex计算要使用Unity提供的TRANSFORM_TEX宏:
o.mainTex = TRANSFORM_TEX(v.texcoord, _MainTex);
但这样mainTex就不再是Plane模型上的原始uv了。而我们并不希望底图的Tiling和Offset会影响流光的位置,因此我们还需要给v2f结构体添加一个flowUV成员来记录Plane的原始uv:
v2f vert (appdata_t v)
{
...
o.flowUV = v.texcoord;
return o;
}
最终的shader代码如下:
Shader "Inception-Fx/FlowInsideOut" {
Properties {
_MainTex ("Base Texture (RGB)", 2D) = "white" {}
_MainColor ("Base Color", Color) = (1, 1, 1, 1)
_FlowTex ("Flow Texture (RGB)", 2D) = "black" {}
_FlowColorAtCenter ("Flow Color At Center", Color) = (1, 1, 1, 1)
_FlowColorAtEdge ("Flow Color At Edge", Color) = (1, 1, 1, 1)
_Period ("Period (Seconds)", float) = 1
_FlowWidth ("Flow Width", Range(0, 1)) = 0.1
_FlowHighlight ("Flow Highlight", float) = 1
[Toggle] _InvertDirection ("Invert Direction?", float) = 0
_AllAlpha ("All Alpha", Range(0, 1)) = 1
}
SubShader {
Tags {"Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent"}
Cull Off
Lighting Off
ZWrite Off
Fog { Mode Off }
Blend One One
Pass {
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
//#pragma only_renderers gles d3d9 gles3 metal
#include "UnityCG.cginc"
sampler2D _MainTex;
float4 _MainTex_ST;
fixed4 _MainColor;
sampler2D _FlowTex;
fixed4 _FlowColorAtCenter;
fixed4 _FlowColorAtEdge;
float _Period;
float _FlowWidth;
float _FlowHighlight;
float _InvertDirection;
float _AllAlpha;
struct appdata_t {
float4 vertex : POSITION;
half2 texcoord : TEXCOORD0;
};
struct v2f {
float4 pos : POSITION;
half2 mainTex : TEXCOORD0;
half2 flowUV : TEXCOORD1;
};
v2f vert (appdata_t v)
{
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
o.mainTex = TRANSFORM_TEX(v.texcoord, _MainTex);
o.flowUV = v.texcoord;
return o;
}
fixed4 frag (v2f i) : COLOR
{
fixed4 baseColor = tex2D(_MainTex, i.mainTex) * _MainColor;
float2 center = float2(0.5, 0.5);
float r = distance(i.flowUV, center);
float radiusMax = 0.5; // 从(0.5, 0.5)到(0.5, 1)的距离
float timeProgress = fmod(_Time.y, _Period) / _Period;
float flowProgress = _InvertDirection * (1 - timeProgress) + (1 - _InvertDirection) * timeProgress;
float flowRadiusMax = flowProgress * (radiusMax + _FlowWidth);
float flowRadiusMin = flowRadiusMax - _FlowWidth;
float isInFlow = step(flowRadiusMin, r) - step(flowRadiusMax, r);
float2 flowTexUV = float2((r - flowRadiusMin) / (flowRadiusMax - flowRadiusMin), 0);
fixed4 flowColor = _FlowColorAtCenter + flowProgress * (_FlowColorAtEdge - _FlowColorAtCenter);
fixed4 finalColor = baseColor + isInFlow * _FlowHighlight * tex2D(_FlowTex, flowTexUV) * flowColor * baseColor;
finalColor *= _AllAlpha;
return finalColor;
}
ENDCG
}
}
FallBack "Diffuse"
}