本系列为作者学习UnityShader入门精要而作的笔记,内容将包括:
- 书本中句子照抄 + 个人批注
- 项目源码
- 一堆新手会犯的错误
- 潜在的太监断更,有始无终
总之适用于同样开始学习Shader的同学们进行有取舍的参考。
UnityShader中的内置变量(时间)
动画效果的实现往往是通过把时间添加到一些变量的计算中,以便在时间变化时画面也随之变化。UnityShader中提供了一系列关于时间的内置变量来允许我们方便地在Unity中访问运行时间:
名称 | 类型 | 描述 |
---|---|---|
_Time | float4 | t是自场景加载开始所经过的时间,4个分量的值分别是(t/20,t,2t,3t) |
_SinTime | float4 | t上述_Time时间的正弦值,4个分量的值分别是(t/8,t/4,t/2,t) |
_CosTime | float4 | t上述_Time时间的余弦值,4个分量的值分别是(t/8,t/4,t/2,t) |
unity_DeltaTime | float4 | dt是时间增量,4个分量的值分别是(dt,1/dt,smoothDt,1/smoothDt) |
纹理动画
序列帧动画
让我们试着用Shader来实现序列帧动画,其实原理很简答,就是需要每帧切换一张图片即可。而如果是用Shader为表面进行渲染的话,那么切换图片实际上就相当于UV运动。
那么这个运动结果应当由经过的时间来表示,而时间对于UV的映射则应当与行列相关,也就是随着时间推移,uv的移动是逐行逐列在运动的:
Shader "Custom/ImageSequenceAnimation_Copy"
{
Properties
{
_Color("Color Tint",Color) = (1,1,1,1)
_MainTex ("Image Sequence",2D) ="white" {}
_HorizontalAmount("Horizontal Amount",Float) =4
_VerticalAmount("Vertical Amount",Float) = 4
_Speed("Speed",Range(1,100)) = 30
}
SubShader
{
// 由于序列帧图像通常是带有透明通道的,可以视为半透明物体
// 因此我们对其进行透明度混合渲染
Tags{"Queue" = "Transparent" "IgnoreProjector"="True" "RenderType"="Transparent"}
Pass
{
Tags{"LightMode" = "ForwardBase"}
ZWrite Off
Blend SrcAlpha OneMinusSrcAlpha
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "AutoLight.cginc"
#include "UnityCG.cginc"
#include "Lighting.cginc"
fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
float _HorizontalAmount;
float _VerticalAmount;
float _Speed;
struct a2v
{
float4 vertex : POSITION;
float4 texcoord : TEXCOORD0;
};
struct v2f
{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
};
v2f vert(a2v v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.texcoord,_MainTex);
return o;
}
fixed4 frag(v2f i):SV_Target
{
// 总时间 = 经过时间 * 帧率 ,floor取整
float time = floor(_Time.y * _Speed);
// 使用Shader实现序列帧动画的本质就是把时间映射到贴图上
// 这种映射关系要求时间应当是与行列相关的,根据材质来看就应当是逐行地移动到下一张贴图
// 我们应当整个序列帧图视为n行m列的矩阵,贴图采样随着时间沿着这个矩阵逐行运动
// 假设当前的行列坐标为(n,m),则经过的图片数量为 8 * m + n,由此不难得到时间与行列的映射关系:
// Time = row * _HorizontalAmount + column;
float row = floor(time / _HorizontalAmount);
float column = time - row * _HorizontalAmount;
// 注意由于OpenGL图像原点在左下角,所以移动UV时横向应用加法,纵向应用减法
half2 uv = i.uv + half2(column, -row);
// 将矩阵切割,最后采样的UV大小就应当是一个矩阵块的大小
uv.x /= _HorizontalAmount;
uv.y /= _VerticalAmount;
fixed4 c = tex2D(_MainTex, uv);
c.rgb *= _Color;
return c;
}
ENDCG
}
}
FallBack "Transparent/VertexLit"
}
由此我们就得到了播放序列帧的Shader。此外,由于是透明纹理,因此 我们要勾选纹理的Alpha Is Transparency属性,并将行列设置成对应的数量。
滚动的背景
我们也可以使用时间计算来获得游戏中那种可以无限滚动的2D背景,其实原理很简单,就是对纹理的UV坐标进行滚动即可。(甚至不需要Shader来实现,直接用C#对材质中的纹理UV进行平移即可)
如果要实现横轴上不断运动的背景,那么只需使U坐标随着时间平移:
Shader "Custom/ScrollingBackGround_Copy"
{
Properties
{
_MainTex("Base Layer(RGB)",2D) = "white" {}
_DetailTex("2nd Layer(RGB)",2D) = "white" {}
// 控制卷轴不同图层滚动速度
_ScrollX("Base Layer Scroll Speed",Float) = 1.0
_Scroll2X("2nd Layer Scroll Speed",Float) = 1.0
// 控制亮度
_Multiplier ("Layer Multiplier",Float) = 1.0
}
SubShader
{
Tags { "RenderType"="Opaque" "Queue"="Geometry"}
Pass
{
Tags{"LightingMode" = "ForwardBase"}
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _DetailTex;
float4 _DetailTex_ST;
float _ScrollX;
float _Scroll2X;
float _Multiplier;
struct a2v
{
float4 vertex:POSITION;
float4 texcoord : TEXCOORD0;
};
struct v2f
{
float4 pos : SV_POSITION;
float4 uv : TEXCOORD0;
};
// 横向运动背景的原理很简单,就是随时间变换采样的U坐标即可
v2f vert(a2v v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv.xy = TRANSFORM_TEX(v.texcoord,_MainTex) + frac(float2(_ScrollX,0.0) * _Time.y);
o.uv.zw = TRANSFORM_TEX(v.texcoord,_DetailTex) + frac(float2(_Scroll2X,0.0) * _Time.y);
return o;
}
fixed4 frag(v2f i):SV_Target
{
float Time = _Time.y;
// 若将UV坐标计算部分从顶点着色器放到片元着色器采样纹理时,其作用也是一样的
// fixed4 firstLayer = tex2D(_MainTex,i.uv.xy + frac(float2(_ScrollX,0.0) * _Time.y));
fixed4 firstLayer = tex2D(_MainTex,i.uv.xy );
fixed4 secondLayer = tex2D(_DetailTex,i.uv.zw );
fixed4 color = lerp(firstLayer,secondLayer,secondLayer.a);
color.rgb *= _Multiplier;
return color;
}
ENDCG
}
}
Fallback "VertexLit"
}
顶点动画
流动的河流
要实现上图中的2D河流的效果,就是对一个方形面片顶点应用正弦波(余弦波)的变换,并使顶点随着时间周期运动。可以看到上面三种颜色的波浪的波形周期也是不同的。
没想到实现这个效果的过程中遇到了诸多疑问,下面先来解答再亮代码:
- 如果你用这个材质效果应用到quad或者plane上,会发现实现的效果很差很奇怪。为什么会出现这样的情况?原因其实很简单——和模型相关
打开FrameDebug,我们看看模型:
上面的是Quad的面片,下面是我们使用的wave的面片。我们发现wave的面片使用的面数更多,我们应用的变换是在模型空间对顶点应用的,因此面数多的模型顶点更多,波动的效果就越明显。
其次,观察模型的坐标系,我们发现虽然二者都是左手坐标系,但是模型空间下的坐标轴方向是不一致的,wave模型的河流方向是沿着z轴的,而quad是沿着y轴的。其实我们在shader中应用的变换方式,就是在河流的顶点在垂直方向上应用sin值叠加。
根据shader,我们对顶点的x分量应用sin值叠加,那么呈现的效果就是沿着z轴方向出现了sin的波形
所以如果对Quad也使用了相同的材质的话,我们发现波动方向是不一样的,对于Quad变成了前后呈现Sin波动(也是沿着z轴方向呈现sin波形),因此同样的效果在quad上使用时需要叠加在y轴上。
所以在使用shader编写顶点动画的时候,我们尤其需要注意模型的面数(顶点数)以及模型本身的坐标系。
最后我们再解析一下这个Shader吧:
Shader "Custom/Water_Copy"
{
Properties
{
_MainTex ("Main Tex", 2D) = "white" {}
_Color ("Color Tint", Color) = (1, 1, 1, 1)
// 波动幅度
_Magnitude ("Distortion Magnitude", Float) = 1
// 波动频率
_Frequency ("Distortion Frequency", Float) = 1
// 波长的倒数
_InvWaveLength ("Distortion Inverse Wave Length", Float) = 10
_Speed ("Speed", Float) = 0.5
}
SubShader
{
// 设置透明渲染Tag
// DisableBatching不允许进行批处理,批处理会合并所有相关的模型,这些模型各自的模型空间就会丢失
// 我们需要在物体的模型空间下对顶点位置进行偏移,因此需要取消Shader的批处理操作
Tags {"Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent" "DisableBatching"="True"}
Pass
{
Tags { "LightMode"="ForwardBase" }
ZWrite Off
Blend SrcAlpha OneMinusSrcAlpha
Cull Off
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
sampler2D _MainTex;
float4 _MainTex_ST;
fixed4 _Color;
float _Magnitude;
float _Frequency;
float _InvWaveLength;
float _Speed;
struct a2v
{
float4 vertex : POSITION;
float4 texcoord : TEXCOORD0;
};
struct v2f
{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
fixed4 offset :TEXCOORD1;
};
// 在顶点着色器中,直接在模型空间对顶点应用正弦变换
v2f vert(a2v v)
{
v2f o;
float4 offset = float4(1.0,1.0,1.0,1.0);
// 最终结果是(offset.x,0,0,0)
offset = sin(_Frequency * _Time.y + v.vertex.x * _InvWaveLength + v.vertex.y * _InvWaveLength + v.vertex.z * _InvWaveLength) * _Magnitude;
o.offset = offset;
// 为了方便理解,可以先将模型坐标转换为世界坐标
// 然后再变换到ClipPos下,但是offset是在模型空间下应用的,因此也需要转换后使用
fixed4 worldPos = mul(unity_ObjectToWorld, v.vertex);
fixed4 worldOffset = mul(unity_ObjectToWorld, offset);
o.pos = UnityWorldToClipPos(worldPos + worldOffset);
// 书中原来的方法
//o.pos = UnityObjectToClipPos(v.vertex + offset);
o.uv = TRANSFORM_TEX(v.texcoord,_MainTex);
o.uv += float2(0.0, _Time.y * _Speed);
return o;
}
fixed4 frag(v2f i) : SV_Target {
fixed4 c = tex2D(_MainTex, i.uv);
c.rgb *= _Color.rgb;
return c;
}
ENDCG
}
}
FallBack "Transparent/VertexLit"
}
我们将sin函数部分修改为offset.x = sin(_Frequency * _Time.y + v.vertex.z * _InvWaveLength) * _Magnitude;
。并使用假彩色输出return fixed4(0,0,0,normalize(i.offset.x));
我们就能清楚的看见z轴计算偏移对图像的影响,其中黑色部分代表了sin波形的负半轴部分,透明部分代表了sin波形的正半轴部分。
将sin函数部分修改为offset.x = sin(_Frequency * _Time.y + v.vertex.x * _InvWaveLength + v.vertex.y * _InvWaveLength + v.vertex.z * _InvWaveLength) * _Magnitude;
,可以看见对顶点的x轴应用偏移计算,则在z分量上也出现了sin波形的运动
当然归一化显示之后肯定有不对的地方,例如负数部分肯定是归零了。因此我们只用归一化来看看代码对渲染的影响即可。
广告牌
另一种常见的顶点动画就是广告牌技术 ,广告牌技术会根据视角来旋转一个被纹理着色的多边形,使得多边形看起来好像总是面对着摄像机(这个功能如果用Update控制物体始终朝向摄像机也行),广告牌技术被应用于渲染烟雾、云朵、闪光效果等。
广告牌技术的本质就是构建旋转矩阵(就是对物体应用一个旋转变换使得正面始终朝向摄像机方向)。而一个变换矩阵需要三个基向量,广告牌使用的基向量通常是:表面法线(normal),向上的方向(up),以及向右的方向(right)(很合理,因为法线始终是垂直于模型表面的)。
除此之外,我们还需要指定一个锚点(anchor location),这个锚点在旋转过程中是固定不变的,以此来确定多边形在空间中的位置。
那么如何使得这三个基向量相互正交才是难点。因为表面法线和向上的方向往往不垂直。但是我们可以固定其中的一个方向,例如模拟草丛的时候,我们希望广告牌指向上的方向永远是(0,1,0),而法线方向就应该随着视角变化;而当模拟粒子效果时,我们希望广告牌的法线方向是固定的,即总是指向视角方向,指向上的方向则可以发生变化。
总之,需要不同模拟效果时,固定的方向就不一样,但原理都是固定其中一个方向来构建正交基。
- 假设我们想要物体的表面始终朝向摄像机,此时法线方向是固定的,我们根据初始的表面法线和指向上的方向来计算出目标方向的指向右的方向:
- r i g h t = u p × n o r m a l right = up × normal right=up×normal
- 对其归一化后,再根据法线方向(法线固定指向视角方向,而指向上的方向是模型本身空间决定的)和指向右的方向计算出正交的指向上的方向:
- u p ′ = n o r m a l × n o r m a l i z e ( r i g h t ) up'= normal×normalize(right ) up′=normal×normalize(right)
(上图right方向画反了,变成右手坐标系了)
让我们试着渲染一个广告牌效果的半透明物体:
Shader "Custom/Billboard_Copy"
{
Properties
{
_MainTex("Main Tex",2D) = "white"{}
_Color("Color Tint",Color) = (1,1,1,1)
// 垂直率,1为法线垂直与平面,0为平行与平面
_VerticalBillboarding ("Vertical Restraints", Range(0, 1)) = 1
}
SubShader
{
Tags{"Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent" "DisableBatching" = "True"}
Pass
{
Tags{"LightMode" = "ForwardBase"}
ZWrite Off
Blend SrcAlpha OneMinusSrcAlpha
Cull Off
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
#include "AutoLight.cginc"
#include "Lighting.cginc"
sampler2D _MainTex;
float4 _MainTex_ST;
fixed4 _Color;
fixed _VerticalBillboarding;
struct a2v
{
float4 vertex : POSITION;
float4 texcoord : TEXCOORD0;
};
struct v2f
{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD1;
};
v2f vert(a2v v)
{
v2f o;
// 以空间坐标原点为锚点构建法线
float3 center = float3(0, 0, 0);
float3 viewer = mul(unity_WorldToObject,float4(_WorldSpaceCameraPos, 1));
// 法线始终指向view方向
float3 normalDir = viewer - center;
// 对y应用垂直率,_VerticalBillboarding越小,法线方向越落到xz平面上
// 因此_VerticalBillboarding控制了法线在垂直方向上的约束度
// 当_VerticalBillboarding为1时则还是法线方向,当其为0代表完全落在了xz平面上
normalDir.y =normalDir.y * _VerticalBillboarding;
normalDir = normalize(normalDir);
// 根据法线y方向确定向上的方向,若法线正好为(0,1,0)或(0,-1,0)则法线将与up向量方向平行,此时需要将up方向指向模型表面方向(0,0,1)
// 避免两向量平行叉乘出零向量
// 其余情况下则保持平面y轴始终向上,即(0,1,0)
float3 upDir = abs(normalDir.y) > 0.999 ? float3(0, 0, 1) : float3(0, 1, 0);
// 叉乘计算与两向量垂直的向右基向量
float3 rightDir = normalize(cross(upDir, normalDir));
// 再计算法线和向右正交基构成的向上的基向量
upDir = normalize(cross(normalDir, rightDir));
// 计算锚点位置,锚点=顶点位置 + 顶点到锚点的偏移量
float3 centerOffs = v.vertex.xyz - center;
// 变换后的位置是基于锚点的,对centerOffs乘以变换后的基向量(相当于应用了平移的矩阵变换)
float3 localPos = center + rightDir * centerOffs.x + upDir * centerOffs.y + normalDir * centerOffs.z;
// 由于存在平移变换(非线性),因此需要先转为四维矩阵再计算Clip空间
o.pos = UnityObjectToClipPos(float4(localPos, 1));
o.uv = TRANSFORM_TEX(v.texcoord,_MainTex);
return o;
}
fixed4 frag (v2f i) : SV_Target {
fixed4 c = tex2D (_MainTex, i.uv);
c.rgb *= _Color.rgb;
return c;
}
ENDCG
}
}
FallBack "Transparent/VertexLit"
}
此外,该样例中使用的模型是quad不是plane,quad是竖直摆放的,而plane是横着的。对竖向顶点应用的变换当然和横向的不同。(对于横向而言up方向是z方向,法线方向是y方向。解决方法是将上面shader中的z和y作用的代码替换一下)
注意事项
在使用顶点动画的时候,有一些注意事项:
- 使用顶点动画时,批处理往往会破坏这种动画效果,这是由于合并网格后模型空间下使用的一些绝对位置和方向往往会发生改变。我们可以取消批处理来避免这一问题,但是这又会带来Draw Call增加的现象。以广告牌为例,我们可以使用顶点颜色来存储每个顶点到锚点的距离值。
- 如果想要为使用了顶点动画的物体添加阴影,使用内置的阴影pass时往往得不到正确的阴影,因为我们没有在ShadowCaster Pass中处理阴影,因此也需要在此pass中进行同样的顶点处理。
以之前的河流顶点动画为例,我们来绘制阴影:
Shader "Custom/VertexAnimationWithShadow_Copy"
{
Properties
{
_MainTex("Main Tex",2d) = "white" {}
_Color("Color Tint",Color) = (1,1,1,1)
_Magnitude("Distortion Magnitude",Float) = 1
_Frequency("Distortion Frequency",Float) = 1
_InvWaveLength ("Distortion Inverse Wave Length", Float) = 10
_Speed ("Speed", Float) = 0.5
}
SubShader
{
Tags{"Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent" "DisableBatching"="True"}
Pass
{
Tags{"LightMode"="ForwardBase"}
ZWrite Off
Cull Off
Blend SrcAlpha OneMinusSrcAlpha
CGPROGRAM
#pragma mutli_compile_fwdbase
#pragma vertex vert
#pragma fragment frag
#include "Lighting.cginc"
#include "UnityCG.cginc"
#include "AutoLight.cginc"
sampler2D _MainTex;
float4 _MainTex_ST;
fixed4 _Color;
float _Magnitude;
float _Frequency;
float _InvWaveLength;
float _Speed;
struct a2v
{
float4 vertex : POSITION;
float4 texcoord : TEXCOORD0;
};
struct v2f
{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
fixed4 offset : TEXCOORD1;
};
v2f vert(a2v v)
{
v2f o;
float4 offset;
offset = float4(0.0,0.0, 0.0, 0.0);
offset.x = sin(_Frequency * _Time.y + v.vertex.x * _InvWaveLength + v.vertex.y * _InvWaveLength + v.vertex.z * _InvWaveLength) * _Magnitude;
o.pos = UnityObjectToClipPos(v.vertex + offset);
o.uv = TRANSFORM_TEX(v.texcoord,_MainTex);
o.uv += float2(0.0,_Time.y * _Speed);
return o;
}
fixed4 frag(v2f i):SV_Target
{
fixed4 color = tex2D(_MainTex,i.uv) * _Color;
return color;
}
ENDCG
}
Pass
{
Tags{"LightMode"="ShadowCaster"}
CGPROGRAM
#pragma multi_compile_shadowcaster
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
#include "Lighting.cginc"
#include "AutoLight.cginc"
float _Magnitude;
float _Frequency;
float _InvWaveLength;
float _Speed;
struct v2f {
V2F_SHADOW_CASTER;
};
v2f vert(appdata_base v) {
v2f o;
float4 offset;
offset = float4(0.0,0.0, 0.0, 0.0);
offset.x = sin(_Frequency * _Time.y + v.vertex.x * _InvWaveLength + v.vertex.y * _InvWaveLength + v.vertex.z * _InvWaveLength) * _Magnitude;
v.vertex = v.vertex + offset;
TRANSFER_SHADOW_CASTER_NORMALOFFSET(o)
return o;
}
fixed4 frag(v2f i) : SV_Target {
SHADOW_CASTER_FRAGMENT(i)
}
ENDCG
}
}
FallBack "VertexLit"
}
只需在绘制阴影的时候对顶点重复之前的动画代码即可(观察上面shader代码不难发现,其实就是前文中的Fallback VertexLit的ShadowCaster代码中加上了计算顶点动画部分的代码)
注意使用宏的时候,变量的定义必须按照标准名称定义