前言
作为一个没有什么实际项目可以让我去操作练手的shader新手,只能在网上看看别人的一些想法并学习一下。我之前通过科学上网在推特上看到了Joyce[MinionsArt]@minionsart大神(有条件的朋友可以关注一下他,真的很有帮助)的用shader模拟液体晃动的效果,便尝试写了写,大体思路总归是那个思路,但是具体实现还是很有我个人风格的,与他的并不完全一致,可以说是简单了很多。写这篇博客是因为并没有在国内网站上找到类似的实现,所以给大家分享一下。
两种方法介绍
第一种方法是用片元裁剪控制水面高低,然后用背面渲染模拟水面(这是上面提到的Joyce的想法,这种方法实现简单且对液体形状要求比较灵活,但是因为没有水面,所以有些后续效果实现不了)。第二种就是顶点动画,知乎上已经有人实现了一些基本效果,想看具体的实现可以点击这里,也可以继续看我的这篇博文(实现方法类似但是我的在某些细节处理上比较简单)。
最终效果
首先话不多说,上效果图
上面这张图片是第一种方法的最终效果图,第二种方法是一样的,所以我就只写了Shader,没有脚本去控制动画。
第二种方法的效果图如下
具体实现方法
片元裁剪
这种方法对于外面的罩子可以用一个稍大于液体的同样形状的物体来模拟,也可以把罩子和液体当作一个物体渲染,就是在液体的基础上按法线方向挤出一定距离来渲染罩子。关于罩子的具体实现我就不详细说了,让我们言归正传说说液体的实现。
首先我们要解决水面的问题,很简单,就是通过高度的限制来裁剪片元就好,注意需要关闭剔除渲染双面,然后修改反面的颜色来模拟水面颜色。
然后水面的晃动我们可以当作一个振幅随着时间减小的简谐运动,也就是说可以把它这个运动方程当成x和z轴上的正弦波方程来看待,根据位移速度或者旋转的角速度决定了初始振幅的大小(位移是惯性,旋转是摩擦),然后随着时间液体晃动减轻,就是振幅逐渐变小直到为0;振动速度则决定了频率。这部分的计算我们写在C#脚本中,最终我们可以根据这个方程计算出x轴和z轴上的高度变化,然后传入shader中来控制片元的裁剪面。
C#脚本代码如下,请忽视我不是很准确的命名
public class GetVertexHight : MonoBehaviour
{
public float RecoverySpeed = 1;//恢复速度
public float speed = 1;//液体晃动速度
public float VelocityFactor = 1;//速度对振幅的影响因子
public float AngleVelocityFactor = 1;//角速度对振幅的影响因子
public Renderer m_renderer;
Vector3 LastFramePos;//上一帧的位置
Vector3 LastFrameRot;//上一帧的欧拉角度
Vector3 velocity;
Vector3 LastFrameVelocity;
Vector3 AngleVelocity;
Vector3 LastFrameAngleVelocity;
float MaxHeightInX;//X轴上振动的高度,液体表面顶点最低点对应的是它的负数
float MaxHeightInZ;//Z轴上振动的高度
Vector2 MaxAmplitude;//简谐运动初始振幅
Vector2 amplitude;
float time;
void Start()
{
LastFramePos = transform.position;
LastFrameRot = transform.rotation.eulerAngles;
LastFrameVelocity = Vector3.zero;
LastFrameAngleVelocity = Vector3.zero;
velocity = Vector3.zero;
AngleVelocity = Vector3.zero;
time = 0;
if (m_renderer == null)
{
Debug.LogError("Missing renderer");
}
}
// Update is called once per frame
void Update()
{
velocity = (transform.position - LastFramePos) / Time.deltaTime;
AngleVelocity = (transform.rotation.eulerAngles - LastFrameRot) / Time.deltaTime;
LastFramePos = transform.position;
LastFrameRot = transform.rotation.eulerAngles;
if (isAccelerating())
{
time = 0;
Vector2 tempAmplitude = getAmplitude(velocity, AngleVelocity);
MaxAmplitude.x = Mathf.Lerp(MaxAmplitude.x, tempAmplitude.x, 0.5f);
MaxAmplitude.y = Mathf.Lerp(MaxAmplitude.y, tempAmplitude.y, 0.5f);
}
LastFrameVelocity = velocity;
LastFrameAngleVelocity = AngleVelocity;
time += Time.deltaTime;
amplitude.x = Mathf.Lerp(MaxAmplitude.x, 0, time * RecoverySpeed);//振幅随着时间线性减小
amplitude.y = Mathf.Lerp(MaxAmplitude.y, 0, time * RecoverySpeed);
if (velocity == Vector3.zero && AngleVelocity == Vector3.zero)
{
MaxHeightInX = amplitude.x * Mathf.Sin(Mathf.PI * 2 * time * speed);
MaxHeightInZ = amplitude.y * Mathf.Sin(Mathf.PI * 2 * time * speed);
}
else
{
MaxHeightInX = amplitude.x;
MaxHeightInZ = amplitude.y;
}
m_renderer.sharedMaterial.SetFloat("_MaxHeightInX", MaxHeightInX);
m_renderer.sharedMaterial.SetFloat("_MaxHeightInZ", MaxHeightInZ);
}
Vector2 getAmplitude(Vector3 velocity, Vector3 AngleVelocity)
{
Vector2 amplitude = new Vector2(velocity.x * -VelocityFactor + AngleVelocity.z * AngleVelocityFactor,
velocity.z * -VelocityFactor + AngleVelocity.x * -AngleVelocityFactor);//注意正负号,正负号选择我会在文档中说明
return amplitude;
}
bool isAccelerating()
{
if (velocity.magnitude > LastFrameVelocity.magnitude || AngleVelocity.magnitude > LastFrameAngleVelocity.magnitude)
return true;
if(velocity.magnitude == LastFrameVelocity.magnitude && LastFrameVelocity.magnitude != 0)
return true;
if(AngleVelocity.magnitude == LastFrameAngleVelocity.magnitude && LastFrameAngleVelocity.magnitude != 0)
return true;
return false;
}
}
我写了一个函数isAccelerating()来判断当前是否是匀速或加速状态,如果是就根据当前运动速度更新最大振动幅度(水面达到的最高高度),并且让水面始终保持与速度相关的一个倾斜状态,当液体处于静止或减速状态时才开始做简谐运动,如果不这样处理,如果速度不是突然变化而是逐渐变慢,那么停下来时会让非常慢的速度导致振动幅度也非常小。
片元裁剪液体的shader代码如下
Shader "MyShader/FragClip"
{
Properties
{
_Color("Color Tint", Color) = (1, 1, 1, 1)
_TopColor("Top Color", Color) = (1, 1, 1, 1)
_FluidHeight("Fluid Height", Range(-0.5, 0.5)) = 0
_MaxHeightInX("MaxHeightInX", Float) = 0
_MaxHeightInZ("MaxHeightInZ", Float) = 0
_AlphaScale("Alpha Scale", Range(0, 1)) = 0.5
}
SubShader
{
Tags{"Queue" = "Transparent" "IgnoreProjector" = "True" "RenderType" = "Transparent"}
Pass
{
Tags { "LightMode" = "ForwardBase" }
Cull off
ZWrite off
Blend SrcAlpha OneMinusSrcAlpha
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
fixed4 _Color;
fixed4 _TopColor;
float _FluidHeight;
float _MaxHeightInX;
float _MaxHeightInZ;
fixed _AlphaScale;
struct a2v
{
float4 vertex : POSITION;
float3 normal : NORMAL;
};
struct v2f
{
float4 pos : SV_POSITION;
float3 objectPos : TEXCOORD0;
float clipY : TEXCOORD1;
};
float getClipYValue(float4 pos)
{
float height;
height = (_MaxHeightInX * pos.x + _MaxHeightInZ * pos.z) / 2;
return height;
}
v2f vert(a2v v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.objectPos = v.vertex;
o.clipY = getClipYValue(v.vertex) + _FluidHeight;
return o;
}
fixed4 frag(v2f i) : SV_Target
{
if (i.objectPos.y > i.clipY)
clip(-1);
fixed4 col = _TopColor;
col.a = _AlphaScale;
return col;
}
ENDCG
}
Pass
{
Tags { "LightMode" = "ForwardBase" }
Cull Back
ZWrite off
Blend SrcAlpha OneMinusSrcAlpha
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
fixed4 _Color;
fixed4 _TopColor;
float _FluidHeight;
float _MaxHeightInX;
float _MaxHeightInZ;
fixed _AlphaScale;
struct a2v
{
float4 vertex : POSITION;
float3 normal : NORMAL;
};
struct v2f
{
float4 pos : SV_POSITION;
float3 objectPos : TEXCOORD0;
float clipY : TEXCOORD1;
};
float getClipYValue(float4 pos)
{
float height;
height = (_MaxHeightInX * pos.x + _MaxHeightInZ * pos.z) / 2;
return height;
}
v2f vert(a2v v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.objectPos = v.vertex;
o.clipY = getClipYValue(v.vertex) + _FluidHeight;
return o;
}
fixed4 frag(v2f i) : SV_Target
{
if (i.objectPos.y > i.clipY)
clip(-1);
fixed4 col = _Color;
col.a = _AlphaScale;
return col;
}
ENDCG
}
}
FallBack "Specular"
}
使用Vface语义来判断正反面有时候会出错,所以选择了用两个pass分别渲染正反,这段代码中还包括了透明度混合。
顶点动画
顶点动画其实就是在局部坐标系中修改顶点的坐标值和法线方向,水面晃动就是让顶点绕着局部x轴和z轴旋转。需要注意的一点是坐标不止需要修改y值,还需要修改x,z来让当前“水面”缩放,在水量大于50%时水面要放,否则要缩,具体为什么要这么做可以参考我这张图。
还是不理解为什么要缩放可以去看我上面提到的知乎文章。
我用Threshold来控制水面“放”这个过程中参与的顶点,这样就可以保证只有靠外的那圈顶点才会去像球面扩张。还有需要注意的是着色要放在片元中而不是顶点(对y值被压缩的顶点进行水面着色),否则会出现下面这种问题。
我对于水面着色采取的方法就是判断片元法线方向。
顶点动画液体的shader代码如下
Shader "MyShader/VertAnim"
{
Properties
{
_Color ("Color", Color) = (1,1,1,1)
_TopColor("Top Color", Color) = (1,1,1,1)
_FluidHeight("Fluid Height", Range(-0.5, 0.5)) = 0
_Threshold("Threshold", Range(0, 1)) = 0.1
_MaxHeightInX("MaxHeightInX", Float) = 0
_MaxHeightInZ("MaxHeightInZ", Float) = 0
}
SubShader
{
Tags { "RenderType"="Opaque" }
Pass
{
Tags{"LightMode" = "ForwardBase"}
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
struct a2v
{
float4 vertex : POSITION;
float3 normal : NORMAL;
};
struct v2f
{
float4 pos : SV_POSITION;
float3 normal : TEXCOORD0;
};
fixed4 _Color;
fixed4 _TopColor;
fixed _FluidHeight;
fixed _Threshold;
float _MaxHeightInX;
float _MaxHeightInZ;
v2f vert(a2v v)
{
v2f o;
float rate = sqrt((0.5 * 0.5 - _FluidHeight * _FluidHeight) / (v.vertex.x * v.vertex.x + v.vertex.z * v.vertex.z));
float vertexDis = min(rate, 1);
fixed vertexHeight = step(_FluidHeight, v.vertex.y);
v.vertex.y = vertexHeight * _FluidHeight + (1 - vertexHeight) * v.vertex.y;
v.normal = vertexHeight * fixed3(0, 1, 0) + (1 - vertexHeight) * v.normal;
if (vertexHeight == 1)
{
if (rate - 1 < _Threshold && rate - 1 > 0)
v.vertex.xz *= rate;
v.vertex.xz *= vertexDis;
}
float X, Z;
X = atan(_MaxHeightInZ / 2);
Z = atan(_MaxHeightInX / 2);
float3x3 rotMatX, rotMatZ;
rotMatX[0] = float3(1, 0, 0);
rotMatX[1] = float3(0, cos(X), sin(X));
rotMatX[2] = float3(0, -sin(X), cos(X));
rotMatZ[0] = float3(cos(Z), sin(Z), 0);
rotMatZ[1] = float3(-sin(Z), cos(Z), 0);
rotMatZ[2] = float3(0, 0, 1);
v.vertex.xyz = mul(rotMatX, mul(rotMatZ, v.vertex.xyz));
o.pos = UnityObjectToClipPos(v.vertex);
o.normal = v.normal;
return o;
}
fixed4 frag(v2f i) : SV_Target
{
if(i.normal.y > 0.99)
return _TopColor;
return _Color;
}
ENDCG
}
}
FallBack "Diffuse"
}
其实这个代码还有很多地方可以进行优化,我的代码只是提供思路,后续的改进就留给大家了。