在Unity中实现基于粒子的水模拟(二:开始着色)
文章目录
前言
笔者最近在研究Unity的可编程渲染管线,参考的文章地址,之后的项目应该都会基于该渲染管线进行拓展。
同时本文是基于这篇文章的Unity实现,只是一种实现的参考。
由于粒子进行模拟时会有恐怖的Overdraw,所以其实在游戏中实时运行还是有点奢侈了,但是如果只是作为游戏中固定的流体模拟,不需要场景改变效果的话还是可以通过定制来实现很好的效果的。
不过本文还是实时模拟的,在一些细节上并没有实现的很好,比如液体碰撞到物体后的效果实现,因为更物理的粒子实现太奢侈了,因此只是简单的实现,想要更好的效果就自己定制吧。
同时这里将物理帧的数据刷新换为了实时帧,让液体喷出时更连续,同时将开头的循环删去,换为了不能刷新就等待,而不是循环一遍,因为这个循环会导致帧数变得很不稳定,更新后的效果:
在自定义渲染管线中实现喷水效果
一、生成顶点
粒子的生成是通过曲面细分生成的顶点来生成的,也就是在我的这篇文章生成粒子的格式生成的,同时将生成粒子的顶点部分全部放到了一个文件中处理,让整体更加模块化,不像一开始将整个流程放在了一个文件中。
首先在曲面细分的结构体中要有我们传入的所有数据,同时为了让输入与输出区分开定义了两个结构体,但是实际上这两个结构体的数据是一致的,因为需要传递给几何着色器,由几何着色器进行数据计算。
这里提一下,之前搜API时看少了,因为Unity只提供了设置float2格式的uv坐标,但是实际上是可以支持float4的坐标的,需要的话的可以换一下设置数据的方法,更充分的利用每一个数据。
struct TessVertex_All{
float4 vertex : POSITION;
float4 color : COLOR;
float3 normal : NORMAL;
float4 tangent : TANGENT;
float2 uv0 : TEXCOORD0;
float2 uv1 : TEXCOORD1;
float2 uv2 : TEXCOORD2;
float2 uv3 : TEXCOORD3;
float2 uv4 : TEXCOORD4;
float2 uv5 : TEXCOORD5;
float2 uv6 : TEXCOORD6;
};
struct TessOutput_All{
float4 vertex : Var_POSITION;
float4 color : Var_COLOR;
float3 normal : Var_NORMAL;
float4 tangent : Var_TANGENT;
float2 uv0 : Var_TEXCOORD0;
float2 uv1 : Var_TEXCOORD1;
float2 uv2 : Var_TEXCOORD2;
float2 uv3 : Var_TEXCOORD3;
float2 uv4 : Var_TEXCOORD4;
float2 uv5 : Var_TEXCOORD5;
float2 uv6 : Var_TEXCOORD6;
};
然后就是曲面细分的标准格式了,还是那套流程,不过需要注意的是在SRP中的曲面细分支持检测的宏需要自己定义,为了方便我直接删除了,毕竟大部分机器都能够支持了。
//顶点着色器的输入值,直接传递不进行操作
void tessVertAll (inout TessVertex_All v){}
//细分参数控制着色器,细分的前置准备
OutputPatchConstant hullconst(InputPatch<TessVertex_All, 3>v){
OutputPatchConstant o = (OutputPatchConstant)0;
float size = _TessDegree;
//获得三个顶点的细分距离值
float4 ts = float4(size, size, size, size);
//本质上下面的赋值操作是对细分三角形的三条边以及里面细分程度的控制
//这个值本质上是一个int值,0就是不细分,每多1细分多一层
//控制边缘的细分程度,这个边缘程度的值不是我们用的,而是给Tessllation进行细分控制用的
o.edge[0] = ts.x;
o.edge[1] = ts.y;
o.edge[2] = ts.z;
//内部的细分程度
o.inside = ts.w;
return o;
}
[domain("tri")] //输入图元的是一个三角形
//确定分割方式
[partitioning("fractional_odd")]
//定义图元朝向,一般用这个即可,用切线为根据
[outputtopology("triangle_cw")]
//定义补丁的函数名,也就是我们上面的函数,hull函数的返回值会传到这个函数中,然后进行曲面细分
[patchconstantfunc("hullconst")]
//定义输出图元是一个三角形,和上面对应
[outputcontrolpoints(3)]
TessOutput_All hull (InputPatch<TessVertex_All, 3> v, uint id : SV_OutputControlPointID){
return v[id];
}
[domain("tri")]
TessOutput_All domain_All (OutputPatchConstant tessFactors, const OutputPatch<TessOutput_All, 3> vi, float3 bary : SV_DomainLocation){
TessOutput_All v = (TessOutput_All)0;
v.vertex = vi[0].vertex * bary.x + vi[1].vertex*bary.y + vi[2].vertex * bary.z;
v.normal = vi[0].normal * bary.x + vi[1].normal*bary.y + vi[2].normal * bary.z;
v.tangent = vi[0].tangent * bary.x + vi[1].tangent*bary.y + vi[2].tangent * bary.z;
v.color = vi[0].color * bary.x + vi[1].color*bary.y + vi[2].color * bary.z;
v.uv0 = vi[0].uv0 * bary.x + vi[1].uv0*bary.y + vi[2].uv0 * bary.z;
v.uv1 = vi[0].uv1 * bary.x + vi[1].uv1*bary.y + vi[2].uv1 * bary.z;
v.uv2 = vi[0].uv2 * bary.x + vi[1].uv2*bary.y + vi[2].uv2 * bary.z;
v.uv3 = vi[0].uv3 * bary.x + vi[1].uv3*bary.y + vi[2].uv3 * bary.z;
v.uv4 = vi[0].uv4 * bary.x + vi[1].uv4*bary.y + vi[2].uv4 * bary.z;
v.uv5 = vi[0].uv5 * bary.x + vi[1].uv5*bary.y + vi[2].uv5 * bary.z;
v.uv6 = vi[0].uv6 * bary.x + vi[1].uv6*bary.y + vi[2].uv6 * bary.z;
return v;
}
二、偏移模拟
1.接收细分着色器输出的顶点
代码如下(示例):
[maxvertexcount(30)]
void geom(triangle TessOutput_All IN[3], inout TriangleStream<FragInput> tristream)
{
LoadWater(IN[0], tristream);
LoadWater(IN[1], tristream);
LoadWater(IN[2], tristream);
}
LoadWater函数是用来确定该顶点状态的函数,用来进行数据准备以及调用对应的处理函数。
2.根据数据调用对应的处理方法
LoadWater是判断该顶点的运动阶段是碰撞前(曲线阶段)还是碰撞后(自定义偏移阶段),因为不同阶段对数据处理的方式不同,因此采用不同的处理方式。
先确定顶点的启动时间以及顶点的阶段,在同一批的粒子(一个三角面)不是同一时间输出的,而是有一个输出的时间范围,因此需要确定该顶点是否在输出时间。
//这批粒子的启动时间,IN.uv6.x是移动时间
float beginTime = IN.tangent.w - _OutTime - _OffsetTime - IN.uv6.x;
//这个顶点的发出时间,ramdom是一个根据顶点随机的float4数据
float outTime = _OutTime * ramdom.w + beginTime;
//不在顶点发出时间,不进行射出
if(_Time.y < outTime ) return;
接着根据数据判断一下属于哪个阶段以及数据情况,调用对应的处理方法。
//偏移阶段,也就是碰撞后的一段时间
if(partiTime >= 1){
float3 end = (float3)0;
if(step(0.5, IN.color.x))
end = float3(IN.uv3.xy, IN.uv4.x);
else
end = IN.tangent.xyz;
Offset(IN, (_Time.y - outTime - IN.uv6.x) / _OffsetTime,
tristream, ramdom, end );
return;
}
if(step(0.5, IN.color.x)){ //第一条射线射中
OnePointEnd(IN, partiTime, tristream);
}
else{ //第二条射线射中
TwoPointEnd(IN, partiTime, tristream);
}
3.曲线拟合
通过设置的数据进行该顶点位于的位置获取,也就是通过贝塞尔曲线进行曲线确定该时间上顶点应该位于的世界坐标。拟合结束后输出到顶点生成平面的方法(Move_outOnePoint)。
//当第一条射线就碰到物体时执行的方法
void OnePointEnd(TessOutput_All IN, float moveTime, inout TriangleStream<FragInput> tristream){
float3 begin = float3(IN.uv0.xy, IN.uv1.x);
float3 center = float3(IN.uv2.xy, IN.uv1.y);
float3 end = float3(IN.uv3.xy, IN.uv4.x);
IN.vertex.xyz = Get3PointBezier(begin, center, end, moveTime);
Move_outOnePoint(tristream, IN, moveTime, end);
}
//第二条射线碰撞到的情况
void TwoPointEnd(TessOutput_All IN, float moveTime, inout TriangleStream<FragInput> tristream){
float3 begin = float3(IN.uv0.xy, IN.uv1.x);
float3 center = float3(IN.uv2.xy, IN.uv1.y);
float3 end = float3(IN.uv3.xy, IN.uv4.x);
float3 target1 = Get3PointBezier(begin, center, end, moveTime);
begin = end;
center = float3(IN.uv5.xy, IN.uv4.y);
end = IN.tangent.xyz;
float3 target2 = Get3PointBezier(begin, center, end, moveTime);
IN.vertex.xyz = target1 + (target2 - target1) * moveTime;
Move_outOnePoint(tristream, IN, moveTime, end);
}
4.碰撞后的模拟
碰撞后的模拟目前实现的很随便,也就是这篇文章的实现的移动方式,内容很少,因此这个部分是肯定需要根据项目重新设置的。
//偏移阶段
void Offset(TessOutput_All IN, float offsetTime, inout TriangleStream<FragInput> tristream, float4 random, float3 begin){
if(offsetTime > 1 || offsetTime < 0) return;
float3 dir0 = lerp(_VerticalStart, _VerticalEnd, random.xyz);
float3 normal = IN.normal;
//确定旋转矩阵
float cosVal = dot(normalize( normal ), float3(0, 1, 0));
float sinVal = sqrt(1 - cosVal * cosVal);
float3x3 xyMatrix = float3x3(-cosVal, sinVal, 0,
-sinVal, -cosVal, 0,
0, 0, 1);
float3x3 yzMatrix = float3x3(1, 0, 0,
0, -cosVal, sinVal,
0, -sinVal, -cosVal);
float3x3 xzMatrix = float3x3(-cosVal, 0, -sinVal,
0, 1, 0,
sinVal, 0, -cosVal);
float3 targetDir = mul(xzMatrix, mul( yzMatrix, mul(xyMatrix, dir0) ));
IN.vertex.xyz = begin + targetDir * offsetTime;
Offset_outOnePoint(tristream, IN, offsetTime);
}
三、着色
1.片元着色器输入
首先描述一下片源着色器的输入结构体,因为这个输出的处理方式和这篇文章是一样的,因此只描述一下输入的数据。
struct FragInput{
//这个粒子平面的uv坐标,和particle system的粒子uv分布一样
float2 uv : TEXCOORD0;
float4 pos : SV_POSITION;
//目前没有用到该数据,因此目前就是粒子总时间
float time : TEXCOORD2;
//x为0时是曲线阶段,1时为碰撞后
//zyw存储了这个粒子的球面中心
float4 otherDate : TEXCOORD3;
//存储世界空间位置
float3 worldPos : TEXCOORD4;
float4 otherDate2 : TEXCOORD5; //预留数据
};
2.生成宽度数据
生成宽度数据的格式很简单,直接根据纹理颜色值返回就行了,我用的纹理是一张带透明通道的圆形图,采集纹理颜色后根据对应的阶段来乘以其的透明度就行了。
float4 col = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv) * _Color;
#ifdef _CURVE_ALPHA
if(i.otherDate.x < 0.1){
col *= saturate( LoadCurveTime( i.time.x, _MoveAlphaPointCount, _MoveAlphaPointArray ) );
}
else {
col *= saturate( LoadCurveTime( i.time.x, _OffsetAlphaPointCount, _OffsetAlphaPointArray ) );
}
#endif
实际上宽度数据最重要的是纹理的混合模式,因为要让数据叠加,因此要使用的混合模式是One One,不过为了更好的定义,可以通过设置混合模式为选项来控制混合效果。
[Enum(UnityEngine.Rendering.BlendMode)] _SrcBlend ("Src Blend", Float) = 1
[Enum(UnityEngine.Rendering.BlendMode)] _DstBlend ("Dst Blend", Float) = 0
[Enum(Off, 0, On, 1)] _ZWrite ("Z Write", Float) = 1
叠加模式的宽度图:
3.生成法线和深度数据
由于在渲染深度时需要深度写入,因此我顺便将法线数据也一同写入了。
在粒子中生成法线的方式很简单,因为首先我们需要获得当这个粒子为球时的球心,其实就是生成粒子的根据点沿摄像机方向原理球的半径。
//worldVer是根据顶点的世界坐标,paritcleLen为半径
float3 sphereCenter = worldVer - UNITY_MATRIX_V[2].xyz * paritcleLen;
通过圆心的位置与该像素的世界坐标的差来得到法线数据,不过直接这样会在圆的外部也有数据(因为粒子是一个平面),因此需要根据透明度剔除。
float4 col = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv);
#ifdef _CURVE_ALPHA
if(i.otherDate.x < 0.1){
col *= saturate( LoadCurveTime( i.time.x, _MoveAlphaPointCount, _MoveAlphaPointArray ) );
}
else
col *= saturate( LoadCurveTime( i.time.x, _OffsetAlphaPointCount, _OffsetAlphaPointArray ) );
#endif
clip(col.a - 0.5); //剔除的位置
float3 normal = normalize(i.worldPos - i.otherDate.yzw);
//由于纹理不能存储负数,需要进行数据映射
return float4(normal * 0.5 + 0.5, 1);
生成的法线图,深度图因为精度问题,就不显示了,反正都是全黑
总结
1.一点小问题
到此就完成了3个纹理的渲染了,这里需要说明一下,我这里的渲染的调用是通过可编程渲染管线调用的,因为其可以让数据渲染到我想要的位置,但是如果是在默认管线中是不能这么操作的。
因为默认管线渲染只能通过指定Camera的TargetTexture来渲染,通过指定两个摄像机渲染,可以渲染出这两张图片,不过这里有一个小问题,就是我没有找到Unity中传递纹理默认的深度数据的方式,导致一开始还用了一个摄像机来渲染深度。
2.补充
实际上我这里的粒子模拟是有很大问题的,因为两个阶段导致有交叉问题,比如边缘的法线顶替了曲线的法线,但是实际上边缘的法线的水不怎么厚,因此我觉得这种模式的粒子模拟方式不好,不过如果是通过粒子来模拟水流之类的效果应该会好很多,成本也低。
同时可以因为水有厚度值,如果是模拟牛奶之类的使用BTDF效果模拟也会很不错。