效果如下
最近在游戏公司实习,终于真正的接触到游戏开发也算是肥宅圆梦了(#大雾),而这个看起来很奇怪的效果就是项目中的一个需求。大致说一下现在参与开发的游戏项目吧,项目是一个FPS手游,不同的是发射出去的“子弹”是类似油漆样的颜料,会在地图上涂上颜色,这个屏幕特效就是用来实现玩家被敌方颜料击中时的屏幕效果。
之前项目对于这个需求的实现方式是采用一个面片播放帧动画,然后遮挡在屏幕前,当时的效果大概是这样
由于是要模拟出液体流动的效果,对帧数要求比较高,在帧动画帧数不足的情况下会感觉到明显的卡顿不流畅。而固定大小的面片与不同的屏幕分辨率也有不适配问题。也无法动态调整特效覆盖范围。
需求
- 模拟出液体流动效果
- 有油漆颜料的质感
- 可根据玩家血量动态调整特效范围
对于这个特效,后处理可能是更好的解决方案。
以下进入正文
1. 实现油漆材质效果
后处理可以理解为是对贴有渲染贴图的四个顶点的矩形面进行二次渲染,没法像常规渲染流程那样以进行纹理采样、计算光照的方式实现出纹理的质感。后来才了解到 Material Capture ,应该解决这类问题的标准答案
2. 实现颜料的流动效果
直觉是使用噪声贴图做出消融效果,然后滚动uv产生移动的效果。然鹅实际上这么做出来的效果非常“硬”,消融和移动效果都是非常整体的,一点也不像液体流动的感觉,而是一张窗花剪纸在屏幕上摩擦的感觉。
3. 不遮挡中心区域
玩家肯定不希望一被击中屏幕就被遮挡得什么也看不到,所以屏幕中央要做一些遮罩处理,而使用遮罩贴图保护的区域会和旁边产生非常整齐的边,像一个相框嵌在屏幕前,效果也很不理想。
matcap的思路是用法线xy分量直接对上面这样的材质图进行采样,把采样结果当做光照信息而不用真的进行光照计算,这样的方式是非常节省性能的,但是短板也非常明显:光照结果是固定的,不会随着视角的变换而变换,一动就露馅,对于这个问题也可以采用Reflection Cube Map指导光照。 参考资料
有了matcap tex,还差用来采样的法线信息,对于一个矩形面片显然是不存在什么法线信息的,所以可以使用一个水波法线贴图来采集法线信息。
PS 使用一个水波法线贴图来获取法线的思路后面也被否定了,虽然模拟出的水波效果非常好,但是和颜料形状的变换(消融)没有相关性,结果就是一个面上产生出与之形状不匹配的光影效果,看起来像纸片一样,并没有正确的材质效果。
这个效果一直没有好的思路去实现,参考了很多后效也没有随机消融、流动的效果,比较相似的是用噪声模拟云雾流动的效果,缺点是整体感太强,不像液体的效果。 虽然最终我也没能找到一个合适方法来模拟液体流动的“随机感”,后来还是基于滚动uv和消融来模拟,只是加入了一些trick来尽可能增大随机性,好在最后的效果还算能看。
思路是计算像素距离底部中央的距离,不能简单的将蓝色圆范围内的颜料“剔除”,那样会形成太过整齐的边缘,用距离值去影响插值的threshold,效果会自然很多。
PS 我上述说的 剔除,是一种形象的说法,并不是指 clip,而是将底色(渲染纹理)与颜料颜色插值,对于想要剔除掉颜料颜色,显示本来渲染纹理颜色的像素,其lerpFactor为0。
下面结合完整代码说明
//=============================================================================
// shader for Doodle Project
// Post Effect for being attacked
// created by Lethe @ 2018-7-5
//=============================================================================
Shader "Custom/PE_OnEnemyInk" {
Properties {
[HideinInspector]
_MainTex("RTX",2D) = "white" {}
[Header(Textures)]
_NoiseTex("噪声纹理(Noise)",2D) = "white" {}
_MatcapTex("材质捕获纹理(MatCap)",2D) = "white" {}
[Header(Script Control)]
_Color("主颜色",Color) = (1,1,1,1)
_HpRatio("当前生命值(%)",Range(0.0,1.0)) = 0.5
[Header(Base Control)]
_SampleDis("采样距离",float) = 20
_DissolveMin("最小融解范围",Range(0.2,0.5)) = 0.5
_DissolveMax("最大融解范围",Range(0.5,1.0)) = 0.8
_SpeedX("X轴速度",float) = 2
_SpeedY("Y轴速度",float) = 10
[Header(Additonal Control)]
_RandScale("随机程度",Range(0.0,0.3)) = 0.1
_RandSpeed("变换速度",Range(0.0,3.0)) = 1.0
_SpecScale("高光强度",Range(0.0,1.0)) = 0.5
_CenterY("遮罩中心高度(Y)",Range(0.0,1.0)) = 0.0
_StretchX("遮罩横向拉伸(X)",Range(0.0,1.0)) = 0.9
_StretchY("遮罩纵向拉伸(Y)",Range(0.0,1.0)) = 0.3
}
SubShader {
// ------------ Tags ---------------
// ------------ Render Set -------------
ZTest Always
Cull Off
ZWrite Off
CGINCLUDE
// ------------ Includes ---------------
#include "UnityCG.cginc"
// ------------ Variables --------------
sampler2D _MainTex; half4 _MainTex_ST;
sampler2D _NoiseTex; half4 _NoiseTex_ST; half4 _NoiseTex_TexelSize;
sampler2D _MatcapTex; half4 _MatcapTex_ST;
fixed4 _Color;
fixed _HpRatio;
float _SampleDis;
fixed _DissolveMin;
fixed _DissolveMax;
float _SpeedX;
float _SpeedY;
half _RandScale;
half _RandSpeed;
fixed _SpecScale;
fixed _CenterY;
fixed _StretchX;
fixed _StretchY;
// ------------ Structures -------------
struct a2v {
float4 vertex : POSITION;
half2 texcoord : TEXCOORD0;
};
struct v2f {
float4 pos : SV_POSITION;
float4 uv : TEXCOORD0;
float4 scrPos : TEXCOORD1;
float dissolveFactor : TEXCOORD2;
};
// ----------- vert & frag -------------
v2f vert(a2v v){
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv.xy = TRANSFORM_TEX(v.texcoord,_MainTex);
o.uv.zw = TRANSFORM_TEX(v.texcoord,_NoiseTex);
o.scrPos = ComputeScreenPos(o.pos);
if(v.texcoord.x < 0.5){
o.dissolveFactor = -0.5;
}
else{
o.dissolveFactor = 0.5;
}
return o;
}
fixed4 frag(v2f i):SV_TARGET{
float2 speed = _Time.y * float2(_SpeedX,_SpeedY) * _NoiseTex_TexelSize * 5;
// 对噪声纹理采样
fixed3 dissolve = tex2D(_NoiseTex,i.uv.zw + speed );
// 根据距离和血量计算插值因子
fixed2 viewPortCRD = i.scrPos.xy/i.scrPos.w;
fixed2 scrBottomCenter = fixed2(0.5,_CenterY);
fixed sqDistance = (viewPortCRD.x - scrBottomCenter.x)*(viewPortCRD.x - scrBottomCenter.x)*_StretchX
+(viewPortCRD.y - scrBottomCenter.y)*(viewPortCRD.y - scrBottomCenter.y)*_StretchY;
_HpRatio = clamp(1 - _HpRatio,_DissolveMin,_DissolveMax);
_HpRatio *= sqDistance*3 ;
_HpRatio += sin(_Time.y * _RandSpeed)* _RandScale * i.dissolveFactor;
fixed lerpFactor = smoothstep(-0.02,0.02,_HpRatio - dissolve.r);
// 从噪声纹理中提取法线
fixed center = dissolve.r;
half2 offset = _NoiseTex_TexelSize * _SampleDis ;
half left = tex2D(_NoiseTex,i.uv.zw + offset).r;
half right = tex2D(_NoiseTex,i.uv.zw - offset).r;
float bumpScale = max(0,1/( _HpRatio - center));
half gapLeft = (left - center) * bumpScale;
half gapRight = (right - center) * bumpScale;
half3 bump = normalize(half3(gapLeft,gapRight,1));
// 使用法线采样材质捕获纹理
fixed4 matCapTexColor = tex2D(_MatcapTex,bump.xy*0.5+0.5);
// 使用插值因子将渲染纹理与后效插值混合
fixed4 mainTexColor = tex2D(_MainTex,i.uv.xy);
fixed3 finalColor = _Color.rgb;
finalColor.rgb = finalColor.rgb * matCapTexColor.r + matCapTexColor.g * _SpecScale;
finalColor.rgb = lerp(mainTexColor.rgb,finalColor.rgb,lerpFactor);
return fixed4(finalColor.rgb,mainTexColor.a);
}
ENDCG
pass{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
ENDCG
}
}
FallBack Off
}
都是常规操作,需要说明的是
if(v.texcoord.x < 0.5){
o.dissolveFactor = -0.5;
}
else{
o.dissolveFactor = 0.3;
}
这是增加效果随机性的一个trick,屏幕的左右两半有不同的消融系数,展现的效果是左边在消解的时候,右侧在融合,周期往复。
维护lerpFactor控制颜料颜色的剔除(消融效果、中央不遮挡)
// 对噪声纹理采样
fixed3 dissolve = tex2D(_NoiseTex,i.uv.zw + speed );
// 根据距离和血量计算插值因子
fixed2 viewPortCRD = i.scrPos.xy/i.scrPos.w;
fixed2 scrBottomCenter = fixed2(0.5,_CenterY);
fixed sqDistance = (viewPortCRD.x - scrBottomCenter.x)*(viewPortCRD.x - scrBottomCenter.x)*_StretchX
+(viewPortCRD.y - scrBottomCenter.y)*(viewPortCRD.y - scrBottomCenter.y)*_StretchY;
_HpRatio = clamp(1 - _HpRatio,_DissolveMin,_DissolveMax);
_HpRatio *= sqDistance*3 ;
_HpRatio += sin(_Time.y * _RandSpeed)* _RandScale * i.dissolveFactor;
fixed lerpFactor = smoothstep(-0.02,0.02,_HpRatio - dissolve.r);
首先计算的是 sqDistance,像素与遮罩中心距离的平方,开方是很昂贵的操作,这里也没有必要开方就直接使用距离的平方了,其中 _CenterY 是遮罩中心的高度,_StretchX 、_StretchY 可以将遮罩圆横向纵向拉伸。
然后 _HpRatio 是玩家血量,根据血量调整颜料的范围,_DissolveMin、_DissolveMax 是颜料的最小最大范围,我没有使用新的变量,直接在_HpRatio上计算了,自此_HpRatio有了新的意义:控制插值的threshold,将噪声中提取的 r通道值 与之比较测试,测试失败的像素 lerpFactor 会置为0,也就是只显示底色。
_HpRatio 在用作Threshold计算LerpFactor 之前,还需要乘以距离平方sqDistance,这样对于越靠近中央区域的像素就拥有越大的Threshold,也就越容易被剔除,由此实现自然的中心遮罩效果
最后为了让整体效果不那么死板,_HpRatio += sin(_Time.y * _RandSpeed)* _RandScale * i.dissolveFactor;
这一句使用正弦函数使整体有一会消散一会融合的周期波动效果,_RandSpeed 控制波动周期,_RandScale 控制波动效果对整体效果的影响大小,然后使用顶点着色器中计算的dissolveFactor 让屏幕两半拥有不同的消融趋势和速率。
使用smoothstep()来计算LerpFactor可以将颜料色块边缘交界的锯齿模糊平滑化。
对噪声纹理滚动uv采样来获得动态的效果,这里噪声纹理有两个作用
- 根据噪声纹理采样的 r 通道值来“剔除”(实际是与底色插值)颜料颜色,这是常规消融效果的做法
- 从噪声纹理中提取出法线信息
正如上面所说的,用另一个不相关的水波法线纹理获取的法线信息与颜料动态形状不匹配,得到的结果很不真实,所以希望从噪声纹理中提取出法线信息来
// 从噪声纹理中提取法线
fixed center = dissolve.r;
half2 offset = _NoiseTex_TexelSize * _SampleDis ;
half left = tex2D(_NoiseTex,i.uv.zw + offset).r;
half right = tex2D(_NoiseTex,i.uv.zw - offset).r;
float bumpScale = max(0,1/( _HpRatio - center));
half gapLeft = (left - center) * bumpScale;
half gapRight = (right - center) * bumpScale;
half3 bump = normalize(half3(gapLeft,gapRight,1));
把噪声纹理当做高度图,用中心点与相邻点的高度差来获得法线信息
然后用获取的法线信息对matcap tex采样
// 使用法线采样材质捕获纹理
fixed4 matCapTexColor = tex2D(_MatcapTex,bump.xy*0.5+0.5);
最后用lerpFactor将渲染纹理与颜料颜色插值混合得到最终效果
// 使用插值因子将渲染纹理与后效插值混合
fixed4 mainTexColor = tex2D(_MainTex,i.uv.xy);
fixed3 finalColor = _Color.rgb;
finalColor.rgb = finalColor.rgb * matCapTexColor.r + matCapTexColor.g * _SpecScale;
finalColor.rgb = lerp(mainTexColor.rgb,finalColor.rgb,lerpFactor);
return fixed4(finalColor.rgb,mainTexColor.a);