本文的目标是制作一个虽时间波动的正弦波面,效果如下图
首先在unity中创建一个平面,这没什么可说的。
曲面细分
unity中默认的plane面数是很少的,不足以形成一个光滑的波面,所以第一步是进行曲面细分。
如图所示,我们需要先输入原始的顶点数据,unity中会将他写成vertex shader的形式,但是实际上的vertex shader在后面。
在hull program和domain program中我们可以设定细分的规则。曲面细分的具体原理在此不赘述,会用即可。需要注意的是在hullfun中的参数决定了细分的程度,这里我们直接为它赋值,更科学的方式可能是根据镜头的距离设定它的值。
TessellationFactors hullFun (InputPatch<tessVertexData,3> v) {
TessellationFactors o;
o.edge[0] = _TessellationUniform;//设定的参数
o.edge[1] = _TessellationUniform;
o.edge[2] = _TessellationUniform;
o.inside = _TessellationUniform;
return o;
}
[UNITY_domain("tri")] 表示适用于三角形,还有quad(四边形)
[UNITY_outputcontrolpoints(3)]//输出的控制点数量
[UNITY_outputtopology("triangle_cw")]//输出拓扑结构为顺时针三角形,还有triangle_ccw(逆时针三角形)、line(线段)
[UNITY_partitioning("fractional_odd")]//分数分割模式,还有integer(整数模式)
[UNITY_patchconstantfunc("hullFun")]//细分函数
//hull着色器:定义细分规则
tessVertexData hul (InputPatch<tessVertexData,3> v, uint id : SV_OutputControlPointID) {
return v[id];
}
[UNITY_domain("tri")]
//domain着色器:计算细分后的顶点位置和数据,同时执行顶点着色器
v2g dom (TessellationFactors tessFactors, const OutputPatch<tessVertexData,3> vi, float3 bary : SV_DomainLocation) {
vertexData v;
v.vertex = vi[0].vertex*bary.x + vi[1].vertex*bary.y + vi[2].vertex*bary.z;
v.tangent = vi[0].tangent*bary.x + vi[1].tangent*bary.y + vi[2].tangent*bary.z;
v.normal = vi[0].normal*bary.x + vi[1].normal*bary.y + vi[2].normal*bary.z;
v.uv = vi[0].uv*bary.x + vi[1].uv*bary.y + vi[2].uv*bary.z;
return vert (v);
}
为了清晰地看到网格,我们将网格以线条的方式渲染出来,渲染的原理是用几何着色器计算出每个三角网格的重心,根据三角形的重心坐标到三顶点的最小距离插值颜色。
[maxvertexcount(3)]
//几何着色器
void geo (
triangle v2g v[3],
inout TriangleStream<g2f> tStream
) {
float4 barycenter = (v[0].vertex + v[1].vertex + v[2].vertex)/3;
float3 normal = (v[0].normal + v[1].normal + v[2].normal)/3;
v[0].normal = normal;
v[1].normal = normal;
v[2].normal = normal;
g2f g0, g1, g2;
g0.data = v[0];
g1.data = v[1];
g2.data = v[2];
//
g0.barycentricCoordinates = float3(0, 0, 1);
g1.barycentricCoordinates = float3(0, 1, 0);
g2.barycentricCoordinates = float3(1, 0, 0);
tStream.Append(g0);
tStream.Append(g1);
tStream.Append(g2);
tStream.RestartStrip();
}
fixed4 frag (g2f i) : SV_Target
{
fixed4 col = tex2D(_MainTex, i.data.uv);
float3 barys = i.barycentricCoordinates;
float3 deltas = fwidth(barys);
float3 smoothing = deltas * _WireframeSmoothing;
float3 thickness = deltas * _WireframeThickness;
barys = smoothstep(thickness, thickness + smoothing, barys);
float minBary = min(barys.x, min(barys.y, barys.z));
return float4(lerp(_WireframeColor, col, minBary),1);//
return col;
}
ENDCG
}
以上部分参考了b站lyh萌主的文章https://www.bilibili.com/read/cv16290237
计算着色器
如标题所说,我们要用到一个随时间变化的位移贴图,显然它需要实时生成。可以选择在脚本的update函数中每次创建新的纹理,然后一个一个像素填充它。如果你这样做会发现帧率非常低,尤其当CPU性能不强时,因为CPU不擅长做这种并行计算。所以我们把这个任务交给GPU,这就用到了compute shader。
虽然都是shader,但是unity中compute shader是用以 DirectX 11 样式 HLSL 语言编写的,所以与其他shader写法有所不同,也不在常规渲染管线中。
下图是默认的compute shader,它包含了最重要的几部分。
#pragma kernel CSMain这一句可以理解为声明了CS中的某个函数(函数名为CSMain)。
RWTexture2D<float4> Result;RW其实是Read和Write的意思,Texture2D就是二维纹理,因此它的意思就是一个可以被Compute Shader读写的二维纹理。CS是运行在GPU上的程序,并且独立于渲染管线,所以需要一个载体承担输入或输出。做渲染用途时,我们一般就将一个纹理作为载体。
一般我们shader通常是只读的,大多使用的是sampler2D,然后通过tex2D函数已经UV坐标访问,但RWTexture2D的访问是直接通过Result[uint2(0,0)]来访问,值为float4型。
由于这个纹理是需要读和写的,所以需要使用render texture,而不能是Texture2D。它的创建方式如下,注意需要开启它的读写,并调用create方法。
public RenderTexture Displace;
...
void Start()
{
Displace = new RenderTexture(1024, 1024,0, RenderTextureFormat.ARGBFloat);
Displace.enableRandomWrite = true;
Displace.Create();
...
}
[numthreads(8,8,1)]表示一个线程组的线程数量,即8*8*1,线程组的设定会影响计算效率,不过具体我也不太懂,有大佬懂得希望不吝赐教。在这我们让它保持默认。
最后是核函数,重要的是它可以有几个输入的参数
SV_GroupID:线程组的id
SV_GroupIndex:即在每一个线程组元素里,线程的索引,[numthreads(8,8,1)],则索引范围(0, 0, 0) - (8, 8, 0),
SV_DispatchThreadID:这个就是全局唯一的id,可以理解为一张图片的每个像素坐标
所以上图中id.x id.y分别代表纹理上某个像素的横纵坐标。
写完了一个CS,接下来就是如何使用它。
public class createTexture : MonoBehaviour
{
public ComputeShader cshader;
private int kernelHandle;
...
void Start()
{
...
kernelHandle = cshader.FindKernel("CSMain");
}
void Update()
{
cshader.SetTexture(kernelHandle, "Result", Displace);
cshader.Dispatch(kernelHandle, 1024 / 8, 1024 / 8, 1);
...
}
}
我们需要定义一个compute shader,并为它的核函数定义一个索引(int类型)
将一个render texture传入作为RWTexture2D。
最后根据我们传入的texture大小(1024*1024)调用dispatch方法启动运算
这部分参考了博文
下面是实际用到的CS,我将位移量保存在纹理的R通道中,注意其中的常量PI最好使用宏定义
#pragma kernel CSMain
#define PI 3.14159274f
RWTexture2D<float4> Result;
float time;
[numthreads(8,8,1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{
Result[id.xy] = float4((cos(id.x/ 1024.0f * 2 * PI*5+time) + cos(id.y/1024.0f * 2 * PI*5+time)) * 0.25f + 0.5f, 0, 0, 1);
}
位移贴图
最后就是把位移贴图应用到渲染中。
将compute shader计算好的texture传入shader,命名为_DisplaceTex,读出它的R通道,计算位移并加在法线方向上。
注意这里对纹理的采样必须用tex2Dlod(),因为tex2D不能用在顶点着色器里(不知原因)。
v2g vert(vertexData v)
{
...
float d = pow( tex2Dlod(_DisplaceTex, float4(v.uv.xy, 0, 0)).r, _Power) * _Displacement;
v.vertex.xyz += v.normal * d;
...
}