Shader 学习笔记:水面

第四篇,终于写到一些稍微那么一丢丢好看的效果了,虽然和网络上和生活中各种奆奆比差了十万八千里,但是我还是得写(因为文盲也想有春天),这一点文章我写一写水面的效果,水面应该是最基础的Shader应用了,应该算是前面几篇博客的综合应用吧,写一写这两天整的水面,记一下心得。

水面的要素:

我们生活中看见的水面,我们可以简单分为两个(两张图都是我百度的),基础的水面(左),复杂的水面(右)

    

根据右边,我们可以知道一个比较基础的水面必须要有以下的元素:

  • 透明:水面必须能清楚地看到水下的物体。
  • 透明折射:受到水面的影响,水下的物体会根据水面的情况产生转化。
  • 光源反射:水面最好能波光闪烁的,看到光源在水面上产生的反射。
  • 波浪:水面不是一面镜子,需要让水面动起来,同时扰动折射与反射。
  • 深度:光照会穿透水面深度而衰减,所以越深的地方越不能看清水下。
  • 菲涅尔:在水面较近的地方看远处时不能看到水下,而看近处时水下能清澈见底。

如右图,比较高端大气上档次的水面,还应该有如下的效果:

  • (可选)冲刷:水面靠近物体的地方会产生一些小泡沫和冲刷的波浪。
  • (可选)物体反射:水面能看到旁边的物体的形状,形成反射。
  • (可选)水底焦散:就像上图右边那个图一样,水下最好根据水面光照能体现出一些纹路。

基础的水面必须要做到这几个要素才能稍微真实地模拟一些比较常见的水面,某些小池塘,或者泳池这种,生活中比较常见且比较清澈的水面。

而针对特殊的水面,例如海水平面,湖水平面,这些水相对比较浑浊,则必须要有三个可选效果才能比较好的模拟出来,同时,海水平面和湖水平面对于基础的要素也要更复杂,例如海水的波浪就应该具有更复杂的波形,对于深度有精准的颜色。

我挖个坑,这一篇文章只实现基础的水面。下一篇水面的文章再写复杂的水面。

基础的透明折射反射菲涅尔我们都在之前的文章讲到过,对于波浪来说,实现方法很多(我们文章中两个波浪各自实现一种方法的波浪),对于深度则需要着重描述。


深度

由于现实生活中光线在较浅的情况下还能照射到水下的物体,如果水较深,则光照在水下会慢慢减弱直至消失,所以我们需要使用一种方法获得当前水面离水下的深度,以此来计算不同深度下的水面情况。

坐标的转换

计算深度信息,我们需要从Shader的基础:坐标的转换开始说起。模型空间中的一个顶点最终转换到屏幕坐标当中,其中经历了:模型空间——>世界空间——>观察空间——>裁剪空间——>屏幕空间,但是在代码中,我们常常直接从模型空间转入到了裁剪空间中,在计算深度的时候,我们需要知道具体发生了写什么,我们这里不涉及到矩阵以及具体数值计算,只是推导我们在代码中是如何做的(这在下一篇博客着重写)。

1.模型空间——>世界空间:

模型空间到世界空间实际上是一个点缩放,旋转,平移的过程,这个过程中顶点依照三个的齐次矩阵来对模型空间的顶点进行转换,最终转移到世界空间中去。

2.世界空间——>观察空间:

由于观察空间的原点即为世界空间中摄像机的坐标,所以世界空间中的值,转入视角空间中有两种方法:

一种是我们在纹理那一章讲过的构建观察空间基轴在世界空间中表示,得到有观察空间转入世界空间的齐次转换矩阵,然后将它求逆,得到世界空间转入观察空间的齐次转换矩阵。

二是对观察空间的原点进行逆向操作,假设将它变换到世界空间原点上,然后以此来构建转换矩阵。得到的转换矩阵和上文是一样的,只是我们的思考方式不同。

并用将物体转入到世界空间中去,同时由于其他的坐标系都是左手坐标系,而观察空间为右手坐标系,所以我们需要将Z分量取反。最终,我们得到观察空间中的一个坐标的四维表示,我们假设摄像机此时照射到了一个平面上的一个点A(图中的黑点),那么它的坐标即为:

                                                    \large ViewSpacePosition=(X_{view},Y_{view},Z_{view},1)

并且,该点垂直投影于Z轴上的点到原点长度即为Zview,我们在图中用虚线的长度表示(我们假设此时A点正好在Z轴上,这样好画一点点,PPT作图不容易,下面的图也一样),这个这个点A在空间中即表示为(此时视锥体并没有起到作用,画出它只是表示这个平面在可以被摄像机看到):

 3.观察空间——>裁剪空间:

在观察空间中并没有使用到出现Unity场景中摄像机的视锥体。而是在裁剪空间中,使用视锥体的属性定义裁剪矩阵。在裁剪空间中,视锥体起到对一个三角形面片进行裁剪的功能,通过对观察空间坐标左乘裁剪矩阵,获得一个顶点在裁剪空间中的坐标,这个坐标也称为齐次坐标。这样做有两个目的:

一是为投影做准备,做好前期的变换以便于投影。裁剪空间的顶点坐标的W分量会在左乘裁剪矩阵后屏幕空间的计算中具有特殊意义。

二是对XYZ三个轴进行缩放,一个视锥体有6个裁剪平面,如果单纯地使用6个面进行裁剪比较麻烦。通过裁剪矩阵后,裁剪坐标的W分量成为了一个范围值,即判断XYZ三个值是否在[-W,W]范围内,如果三个分量都满足这个条件,则该顶点处于裁剪空间内,不会被剔除。

那么,通过左乘裁剪矩阵后,上文中的那个点A在裁剪空间中即表示成:

                                                   \large ClipSpacePosition=(X_{clip},Y_{clip},Z_{clip},-Z_{view})

并且,在图中有:

特别需要注意的是,裁剪空间中坐标的W分量为视角空间中的Z分量的负数,这也是我们获得深度的基础。

4.裁剪空间——>屏幕空间:

通过裁剪矩阵,我们获得了裁剪矩阵的四个坐标,但是,最终我们输出到屏幕上的是一个二维的坐标,此时我们需要进行齐次除法:将齐次坐标的W分量除以XYZ分量。获得归一化的设备坐标NDC(Normalized Device Coordinates)。我们写一个非常简单的式子表示一下顶点的NDC坐标:

  • \large \large X_{NDC}=\frac{X_{clip}}{W_{clip}}=\frac{X_{clip}}{-Z_{view}}
  • \large Y_{NDC}=\frac{Y_{clip}}{W_{clip}}=\frac{Y_{clip}}{-Z_{view}}
  • \large Z_{NDC}=\frac{Z_{clip}}{W_{clip}}=\frac{Z_{clip}}{-Z_{view}}

我们获得到的XYZ的NDC坐标处于[-1,1]之间,我们在上一篇博客写到,OpenGL的屏幕原点位于左下角,DirectX的屏幕原点位于右上角,所以还需要将[-1,1]的NDC坐标映射到[0,1]范围内才能匹配到屏幕上。我们之前写法线的时候,写到过一个法线纹理的像素位于[0,1]之间,而法线纹理的xy分量位于[-1,1]之间,当时使用的映射函数是:

normal = pixel * 2 - 1

那么此时的NDC的情况正好相反,所以NDC的映射函数应该是上面的倒过来,为:

\large {NDC}'=\frac{NDC+1}{2}

我们将这个映射函数带入上面的式子,同时由于我们需要带入每次像素的单位(这个和纹素的原理是一致的),所以最终映射在屏幕中一个像素的坐标为:

  • \large X_{​{screen}}=(\frac{X_{clip}+1}{2W_{clip}})*pixelWidth
  • \large Y_{​{screen}}=(\frac{Y_{clip}+1}{2W_{clip}})*pixelHeight

屏幕是二维的,那么NDC的Z分量似乎没有用处了,但其实深度深度纹理对应的像素正是通过NDC的Z分量而来的,与上面的XY一样需要映射:

                                                         \large depth=\frac{Z_{NDC}+1}{2}=\frac{Z_{clip}+1}{2W_{clip}}=-\frac{Z_{clip}+1}{2Z_{view}}

这个深度值depth即为一个像素在深度纹理中储存的对应值,我们可以通过对深度纹理的采样来获得它。

当我们在屏幕上渲染一个像素时,需要设定模型的RenderType是否为Opaque,判断渲染队列的值是否为2500以下(即小于Transparent的队列值),如果满足条件,就将它渲染在深度纹理上。

我们首先先摆个“池塘”出来,右边是它线性的深度图:

我们在Shader中对深度图进行采样时,由于裁剪矩阵的压缩,得到的深度并不是像图中这样线性和明显(上图中的深度图经过Unity处理),我们回到视角空间的那张图,可以知道:一个像素对于摄像机的深度即为它在视角空间下的Z轴分量

同时,由于视角空间使用右手坐标系,摄像机看见的物体的Z分量为负值,所以这里需要取反:

                                                               \large Depth_{linear}=-Z_{view}=\frac{Z_{clip}+1}{2depth}

并且,由于公式中所需的齐次坐标Z轴也根据Zview与远近裁剪平面计算得来,所以最终我们可以解这个一元一次方程知道具体的线性深度数值。这个数值即为这个像素在视角空间的Z分量,范围为[Near,Far](即近裁剪平面到远裁剪平面),我们代码中并不需要这样计算,只需要执行特定的函数:LinearEyeDepth即可,它的形参是屏幕空间下的深度值:

                //获得屏幕空间下的深度值
                float ScreenDepth=tex2Dproj(_CameraDepthTexture,VToF.srcPos);

                //获得视角空间下的深度值,范围为[Near,Far]
                float ViewDepth=LinearEyeDepth(ScreenDepth);

同时,我们也可以得到[0,1]范围的深度值,只需要将这个depth除以我们远裁剪平面的值即可(即表示最远的范围为1),对此也存在函数Linear01Depth即可。这两个函数内部都使用了_ZBufferParams来获得摄像机近远裁剪平面的距离。


屏幕纹理采样:

我们上文中的深度图和GrabPass获得的屏幕图像都属于屏幕纹理,我们在之前就用到过,很遗憾没有把它采样讲清楚,我们趁着这个机会细细的写一写。我们采样一个物体的纹理时,其实就是通过一个2维坐标来获得图片对应纹理坐标的像素。

  • 对于一个普通模型纹理来说,这个二维坐标即为模型的纹理坐标UV。
  • 对于屏幕纹理来说,这个二维坐标即为像素在屏幕上的坐标。

要获得屏幕坐标,其实就是手动完成我们上述的空间操作。我们上面已经把像素对应的屏幕坐标的计算过程得出来了,它是根据裁剪坐标进行齐次除法计算出来,最终得到用于采样的屏幕坐标为:

                                                        \large X_{​{screen}}=\frac{X_{clip}+1}{2W_{clip}}         \large Y_{​{screen}}=\frac{Y_{clip}+1}{2W_{clip}}

我们在顶点着色器中往往开头第一句话就是将模型空间顶点转入裁剪空间中,所以很容易获得裁剪空间坐标,看起来最终的屏幕坐标唾手可得了,从上面的描述来看,手动获得屏幕坐标只需要两步:

  • 使用透视除法获得NDC
  • 将NDC从[-1,1]映射到[0,1]

但事实上,我们手动计算屏幕坐标时,这两个步骤反了过来,我们需要先将裁剪坐标应用映射函数,然后进行齐次除法:

将裁剪坐标应用映射函数:使用ComputeGrabScreenPos函数完成,它的形参是齐次坐标,内部等价于如下的操作(这里省去了判断平台的宏的逻辑):

VToF.pos.x=(VToF.pos.x+VToF.pos.w)/2;
VToF.pos.y=(VToF.pos.y+VToF.pos.w)/2;
//ZW两个分量保持不变

ComputeGrabScreenPos输出的形式为(只修改XY分量,ZW分量不变):

                                                    \large X_{​{outPut}}=\frac{X_{clip}+W_{clip}}{2} ,  \large Y_{​{outPut}}=\frac{Y_{clip}+W_{clip}}{2}

 由于顶点着色器与片元着色器存在插值,所以最终的齐次除法被转移至片元着色器中计算,那么最终通过齐次除法我们得到的屏幕坐标为:

         \large X_{screen}=\frac{X_{outPut}}{W_{clip}}=\frac{X_{clip}+W_{clip}}{2W_{clip}}=\frac{X_{clip}+1}{2W_{clip}}                     \large Y_{screen}=\frac{Y_{outPut}}{W_{clip}}=\frac{Y_{clip}+W_{clip}}{2W_{clip}}=\frac{Y_{clip}+1}{2W_{clip}}

所以,对于屏幕纹理采样,我们可以使用tex2D函数的如下写法:

fixed3 screenAlbedo=tex2D(_ScreenTexture,VToF.srcPos.xy/VToF.srcPos.w);

同时,它也等价于使用tex2Dproj函数的如下写法:

fixed3 screenAlbedo=tex2Dproj(_ScreenTexture,VToF.srcPos);

 tex2Dproj专用于屏幕纹理的采样,它的第二个形参是一个float3或者float4类型的参数,它内部会将float3或float4前两个分量除以最后一个分量,相当于自动帮我们做了齐次除法。我们在采样屏幕纹理(例如GrabPass和深度纹理)时,可以选择二者使用。

同时,为了保证不同平台的一致性,Unity提供了一个宏SAMPLE_DEPTH_TEXTURE来进行纹理采样,但它内部使用了tex2Dproj函数。所以我们这篇博客里都直接使用了tex2Dproj函数来采样。


深度纹理的使用: 

我们获得深度后,就可以知道深度纹理中一个对应的像素在视角空间中对应的值。我们需要注意,这个值来源于2500以下的物体在屏幕中的深度绘制,而2500以上的物体的深度并不会绘制到深度图中。针对不会被绘制到深度图的物体来说,它的深度值在上面视角空间的图就可以知道:

像素在片元着色器中获得自己的深度值即为:像素在视角空间下的Z轴分量。即Zview

我们在Shader中获得这个值也同样存在两种方法:

1.将顶点从模型空间转入视角空间,单拎出视角空间Z轴分量取负数即为当前深度。这个方法要记住一定要取反,因为视角空间为右手坐标系,所有被摄像机渲染的物体的Z分量都是负值,所以必须取负值才能获得正确的深度。

VToF.viewPos=UnityObjectToViewPos(v.vertex);
float myDepth=-VToF.viewPos.z;

2.裁剪空间下的W轴分量即为视角空间Z轴分量的负数,这个更简单,连取反都不需要了。并且,我们通过计算屏幕坐标的时候就已经获得了裁剪空间的W轴分量:

float myDepth=VToF.srcPos.w;

那么,深度差即为:当前深度减去深度图中对应深度:

float DepthDeference=(Lineardepth-myDepth);

 我们画一张图,表示一下当深度图中的透明队列像素A与深度图中像素B的深度差关系:

我们写一个例子,让一个平面下随着深度越深颜色由绿到黑:

Shader "Hidden/DepthTest"
{
    Properties
    {
        _FloorColor("FloorColor",Color)=(0,0,0,0)
        _DepthColor("DepthColor",Color)=(0,0,0,0)
        _DepthBlend("DepthBlend",float)=1
        _TransparentAmount("TransparentAmount",Range(0,1))=0.5
    }
    SubShader
    {
        Tags
        {
            "Queue"="Transparent"
        }
        GrabPass
        {
            "_ScreenTex"
        }
        Pass
        {
            CGPROGRAM

            #pragma vertex myVertex
            #pragma fragment myFragment
            #include "UnityCG.cginc"

            float4 _FloorColor;
            sampler2D _ScreenTex;
            float4 _ScreenTex_TexelSize;
            float _TransparentAmount;
            sampler2D _CameraDepthTexture;
            float4 _DepthColor;
            float _DepthBlend;

            struct VertexData
            {
                float4 vertex:POSITION;
            };

            struct VertexToFragment
            {
                float4 pos:SV_POSITION;
                float4 srcPos:TEXCOORD1;
            };

            VertexToFragment myVertex(VertexData v)
            {
                VertexToFragment VToF;
                VToF.pos=UnityObjectToClipPos(v.vertex);
                VToF.srcPos=ComputeGrabScreenPos(VToF.pos);
                return VToF;
            }
            fixed4 myFragment(VertexToFragment VToF):SV_TARGET
            {
                float depthSample=tex2Dproj(_CameraDepthTexture,VToF.srcPos);
                float depth=LinearEyeDepth(depthSample);
                float waterDepth=saturate(_DepthBlend*(depth-VToF.srcPos.w));

                float4 ScreenColor=tex2Dproj(_ScreenTex,VToF.srcPos);

                float4 finalDepthColor=lerp(_DepthColor,_FloorColor,waterDepth);

                float4 getColor=lerp(ScreenColor,finalDepthColor,_TransparentAmount);
                return getColor;
            }
            ENDCG
        }
    }
}

 效果还是可以的(虽然没有我想象中那么恶心的效果),如下:


波浪

实现一个简单水面波浪的效果办法很多,比如:

  • 使用法线纹理的偏移,然后将偏移量影响到屏幕纹理采样中。
  • 使用三角函数调整面片的波形,然后将波形映射到UV中,再使用纹理坐标将屏幕坐标采样偏移。
  • 使用噪波贴图采样,然后计算模型坐标高度,最后使用噪波贴图偏移屏幕纹理。

第一种乍一看效果很好,但是没有具体顶点的变化,而且很依赖法线贴图的精度和细腻程度,所以靠近看可能有点假。第二个比较舒服,效果最好,但是对顶点面数有要求,如果面数低了很可能就比较丑陋,如果面数过多又有可能拖累性能。第三个同样的对面数有要求,并且需要在顶点着色器中抓取纹理。

顶点着色器中的纹理采样:

纹理采样函数tex2D实际上是一个快捷方式,实际上内部是“找出正确的mip级别来自动进行采样”,在片元着色器中,这是使用隐式导数完成的,但是这些在顶点着色器中不可用。

所以,我们需要使用更明确的tex2Dlod函数,它既能在顶点着色器中使用又能在片元着色器中使用。 它的第二个形参是一个float4变量,其中X分量和Y分量是纹理坐标,W分量指定为MipMap的采样级别。

仿真水面效果:

完成水面的反射折射菲涅尔和法线纹理偏移(在世界空间中计算),并且把之前的多光照也安排上看看效果:

Shader "Hidden/Water"
{
    Properties
    {
        _WaveMap("WaveMap",2D)="white"{}
        _speedX("WaveSpeedX",float)=1.0
        _speedY("WaveSpeedY",float)=1.0
        _Disortion("Disortion",Range(0,100))=10
        _FresnelScale("FresnelScale",Range(0.0,1.0))=1.0
        _Gloss("Gloss",Range(8,100))=10
        _BumpScale("BumpScale",float)=1.0
        _DepthBlend("DepthBlend",float)=1
        _DepthColor("DepthColor",Color)=(0,0,0,0)
    }
    SubShader
    {
        CGINCLUDE
        #include "UnityCG.cginc"
        #include "Lighting.cginc"
        #include "AutoLight.cginc"

        sampler2D _WaveMap;
        float4 _WaveMap_ST;
        sampler2D _ScreenTexture;
        float4 _ScreenTexture_TexelSize;
        sampler2D _CameraDepthTexture;
        float4 _DepthColor;

        float _speedX;
        float _speedY;
        float _Disortion;
        float _FresnelScale;
        float _Gloss;
        float _BumpScale;
        float _DepthBlend;
        
        struct VertexData
        {
            float4 vertex:POSITION;
            float2 uv:TEXCOORD0;
            float3 normal:NORMAL;
            float3 tangent:TANGENT;

        };

        struct VertexToFragment
        {
            float4 pos:SV_POSITION;
            float2 WaveUV:TEXCOORD0;
            float3 worldPos:TEXCOORD1;
            float4 srcPos:TEXCOORD2;
            float3 TangentToWorld[3]:TEXCOORD3;
        };

        VertexToFragment myVertex(VertexData v)
        {
            VertexToFragment VToF;
            VToF.pos=UnityObjectToClipPos(v.vertex);
            VToF.srcPos=ComputeGrabScreenPos(VToF.pos);
            VToF.WaveUV=TRANSFORM_TEX(v.uv,_WaveMap);
            VToF.worldPos=mul(unity_ObjectToWorld,v.vertex);

            float3 worldNormal=UnityObjectToWorldNormal(v.normal);
            float3 worldTangent=UnityObjectToWorldDir(v.tangent);
            float3 BioNormal=normalize(cross(worldTangent,worldNormal));
            VToF.TangentToWorld[0]=float3(worldTangent.x,BioNormal.x,worldNormal.x);
            VToF.TangentToWorld[1]=float3(worldTangent.y,BioNormal.y,worldNormal.y);
            VToF.TangentToWorld[2]=float3(worldTangent.z,BioNormal.z,worldNormal.z);

            return VToF;
        }

        fixed4 myFragment(VertexToFragment VToF):SV_TARGET
        {
            float3x3 TangentToWorld=float3x3(VToF.TangentToWorld[0],VToF.TangentToWorld[1],VToF.TangentToWorld[2]);

            float2 speed=_Time.y*float2(_speedX,_speedY);
            float3 tangentNormalA=UnpackNormal(tex2D(_WaveMap,VToF.WaveUV+speed));
            tangentNormalA.xy*=_BumpScale;
            float3 tangentNormalB=UnpackNormal(tex2D(_WaveMap,VToF.WaveUV-speed));
            tangentNormalB.xy*=_BumpScale;
            float3 tangentNormal=normalize(tangentNormalA+tangentNormalB);
            float3 worldNormal=normalize(mul(TangentToWorld,tangentNormal));

            float2 offset=tangentNormal.xy*_ScreenTexture_TexelSize*_Disortion;

            fixed2 srcUV=VToF.srcPos.xy/VToF.srcPos.w+offset;

            float4 depthSample=tex2D(_CameraDepthTexture,srcUV);
            float depth=LinearEyeDepth(depthSample);
            float LinearDepth=saturate(_DepthBlend*(depth-VToF.srcPos.w));

            float3 worldLight=normalize(UnityWorldSpaceLightDir(VToF.worldPos));
            float3 worldView=normalize(UnityWorldSpaceViewDir(VToF.worldPos));

            fixed3 screenAlbedo=tex2D(_ScreenTexture,srcUV);
            fixed3 screenWithDepth=lerp(screenAlbedo,_DepthColor,LinearDepth);

            fixed3 fresnel=_FresnelScale+pow(1-max(0,dot(worldView,worldNormal)),5);

            UNITY_LIGHT_ATTENUATION(atten,VToF,VToF.worldPos);
            fixed3 Diffuse=_LightColor0.rgb*max(0,dot(worldNormal,worldLight));
            fixed3 Specular=_LightColor0.rgb*pow(max(0,dot(worldNormal,normalize(worldLight+worldView))),_Gloss);

            fixed3 finalColor=lerp(screenWithDepth,Diffuse,saturate(fresnel))+Specular;
            return fixed4(finalColor*atten,1);
        }
        ENDCG
        Tags
        {
            "Queue"="Transparent"
        }
        GrabPass
        {
            "_ScreenTexture"
        }
        Pass
        {
            Tags
            {
                "LightMode"="ForwardBase"
            }
            CGPROGRAM
            #pragma vertex myVertex
            #pragma fragment myFragment
            #pragma multi_compile_fwdbase
            ENDCG
        }
        Pass
        {
            Tags
            {
                "LightMode"="ForwardAdd"
            }
            Blend One One
            CGPROGRAM
            #pragma vertex myVertex
            #pragma fragment myFragment
            #pragma multi_compile_fwdadd
            ENDCG
        }
    }
}

我们需要注意的是:

  1. 如果要让两张法线纹理同时偏移向相反的方向,那么我们可能需要将法线按照不同方向的偏移方式采样两次然后加在一起,一起打包送到世界空间中去。
  2. 最后高光最好是叠加而不是放在插值函数中,因为这样可能造成高光反射不明显,我在之前经常犯这个毛病。高光应该独立地和插值结果加起来。

除了这一点以外其他的都在之前的博客中描述过,然后我们拿之前的池塘看看效果,并且因为使用了多光照,所以这里放两个点光源:

水面动态图(我截了一个晚上才截出这张大小不超标的图,不会GIF截图害死人):

 菲涅尔效果(说实话效果不咋地):

多光照效果(说实话我最喜欢这一张):

使用噪声纹理偏移坐标y轴的效果(由于特别丑所以我放出来“以儆效尤”):


卡通水面的效果 

卡通水面的话就不能再使用法线纹理偏移了,我们这里使用顶点的波浪,即在顶点着色器中使用正弦波来进行偏移,一般说来,如果要让一个平面每个顶点的波长都不一样,就需要对使用这个平面的X轴和Z轴参加波长的计算,我们这里只算是对Shader中比较简单的波的应用(以后会找机会专门写一篇关于Shader中波的应用(前提是我得自己得懂))。

高中学过的一个基本的正弦波如下:

\large Asin(\omega x+\phi )

我们将一个顶点带入正弦公式,那么有如下的参数需要填补:

  • φ:由当前的顶点的X或Z轴指定
  • ωx:频率与变量x由Shader时间变量_Time指定
  • A:振幅可以手动指定

如果只是将顶点坐标改变,将偏移代入任何阶段的顶点中都可以,但是如果我们需要让它影响最终的纹理的扰动,而不能只扰动模型空间的顶点,并且不能让它参与屏幕坐标的计算,那么需要让这个值直接参与裁剪空间以后的运算,否则会导致扰动效果不明显。

然后我们需要将对应的扰动应用到纹理采样的坐标上,就像上篇文章中卷积核的采样一样,我们如果要获得某个像素A偏移以后的像素B,那么需要以这个像素为原点,将偏移乘以纹素,然后与原本的纹理坐标相加。

但是在之前的偏移计算中,偏移量往往都是法线向量或者卷积核指定坐标,在顶点波浪的偏移中,由于偏移量已经应用到了顶点裁剪坐标中,我们可以使用平面纹理坐标或者使用屏幕坐标当做偏移量来计算偏移。

我们写一个很单纯的X轴正弦波,并且使用很久一篇博客写过的一个平面(这个平面可以自由调节顶点密度),来实现一下基础的顶点波浪:

Shader "Hidden/GrabTest"
{
    Properties
    {
        _WaveHight("WaveHeight",float)=1.0
        _WaveSpeed("WaveSpeed",float)=1
        _WaveDirectionX("WaveDirectionX",float)=1.0
        _Disortion("Disortion",float)=5.0
    }
    SubShader
    {
        Tags
        {
            "Queue"="Transparent"
        }
        GrabPass
        {
            "_ScreenTexture"
        }
        Pass
        {
            CGPROGRAM
            #pragma vertex myVertex
            #pragma fragment myFragment

            #include "UnityCG.cginc"

            sampler2D _ScreenTexture;
            float4 _ScreenTexture_TexelSize;
            
            float _WaveHight;
            float _WaveSpeed;
            float _WaveDirectionX;
            float _Disortion;

            struct VertexData
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct VertexToFragment
            {
                float4 srcPos:TEXCOORD0;
                float4 pos : SV_POSITION;
            };

            VertexToFragment myVertex(VertexData v)
            {
                VertexToFragment VToF;
                VToF.pos=UnityObjectToClipPos(v.vertex);
                VToF.srcPos=ComputeGrabScreenPos(VToF.pos);
                
                float4 offset=float4(0,0,0,0);
                offset.y=_WaveHight*(sin(_Time.y*_WaveSpeed+v.vertex.x*_WaveDirectionX));
                VToF.pos+=offset;

                return VToF;
            }


            fixed4 myFragment(VertexToFragment VToF) : SV_Target
            {
                float2 offset=(VToF.srcPos.xy/VToF.srcPos.w)*_Disortion*_ScreenTexture_TexelSize;
                fixed3 ScreenColor=tex2D(_ScreenTexture,VToF.srcPos.xy/VToF.srcPos.w+offset);
                return fixed4(ScreenColor,1.0);
            }
            ENDCG
        }
    }
}

 这个效果简单粗暴,但是还是很神奇的:

但是我们在具体使用写Shader的时候,肯定不能用这么简陋的正弦波,我们可以尝试合成波来实现一个比较正确的波形:

y=sin(x)+sin(z)+sin(x+z)

那么上面的简单的波形就变成了如下的样子:

我们最终调整这个波形,然后进行纹理和深度的计算,最终做出一个卡通水面的效果: 

Shader "Hidden/CartoonWater"
{
    
    Properties
    {
        _MainTex("Main Tex",2D)="white"{}
        _WaveHight("WaveHeight",float)=1.0
        _WaveSpeed("WaveSpeed",float)=1
        _WaveDirectionX("WaveDirectionX",float)=1.0
        _WaveDirectionZ("WaveDirectionZ",float)=1.0

        _Brightness("BrightNess",float)=1.0
        _TexSpeed("TexSpeed",Range(0,1))=1
        _Disortion("Disortion",float)=1 

        _TransparentSize("TransparentSize",Range(0,1))=1
        _EdgeSize("EdgeSize",float)=1.0
        _EdgeColor("EdgeColor",Color)=(0,0,0,0)
        _OpaqueAmount("OpaqueAmount",Range(-1,1))=1.0

    }
    SubShader
    {
        Tags
        {
            "Queue"="Transparent"
        }
        GrabPass
        {
            "_ScreenTex"
        }
        pass
        {
            CGPROGRAM 
            #pragma vertex MyVertex
            #pragma fragment MyFragment
            #include "UnityCG.cginc"
            #include "Lighting.cginc"

            sampler2D _MainTex;
            float4 _MainTex_ST;

            sampler2D _ScreenTex;
            float4 _ScreenTex_TexelSize;

            sampler2D _WaveTex;
            float4 _EdgeColor;
            float _OpaqueAmount;

            float _WaveHight;
            float _WaveSpeed;
            float _WaveDirectionX;
            float _WaveDirectionZ;
            float _Brightness;
            float _TexSpeed;
            float _Disortion;
            float _EdgeSize;

            float _TransparentSize;

            sampler2D _CameraDepthTexture;
            float4 _CameraDepthTexture_TexelSize;

            struct VertexData
            {
                float3 vertex:POSITION;
                float2 uv:TEXCOORD;
                float3 normal:NORMAL;
                float3 tangent:TANGENT;
                
            };
            struct VertexToFrag
            {
                float4 pos:SV_POSITION;
                float2 uv:TEXCOORD0;
                float4 srcPos:TEXCOORD1;
                float3 worldPos:TEXCOORD2;
                float3 worldNormal:TEXCOORD3;
                float2 MainUV:TEXCOORD4;
            };

            VertexToFrag MyVertex(VertexData v)
            {
                VertexToFrag VToF;

                float waveSpeed=_Time.y*_WaveSpeed;
                float4 offset;
                offset.xzw=float3(0,0,0);
                offset.y=_WaveHight*(sin(waveSpeed+v.vertex.x*_WaveDirectionX+v.vertex.z*_WaveDirectionZ)+sin(waveSpeed+v.vertex.x*_WaveDirectionX)+sin(waveSpeed+v.vertex.z*_WaveDirectionZ));

                VToF.pos=UnityObjectToClipPos(v.vertex);
                VToF.srcPos=ComputeGrabScreenPos(VToF.pos);
                VToF.MainUV=TRANSFORM_TEX(v.uv,_MainTex);
                VToF.uv=v.uv;
                VToF.worldPos=mul(unity_ObjectToWorld,v.vertex);
                VToF.worldNormal=UnityObjectToWorldNormal(v.normal+offset);

                VToF.pos+=offset;
                return VToF;
            }

            fixed4 MyFragment(VertexToFrag VToF):SV_TARGET
            {
                float2 WaveOffset=_Time.y*_TexSpeed*float2(_WaveDirectionX,_WaveDirectionZ);
                fixed4 texColor=tex2D(_MainTex,VToF.uv+WaveOffset)*_Brightness;

                fixed opaque=saturate(texColor.r-_OpaqueAmount);

                float depthSample=tex2Dproj(_CameraDepthTexture,VToF.srcPos);
                float depth=LinearEyeDepth(depthSample);
                fixed getEdge=(1-saturate(_EdgeSize*(depth-VToF.srcPos.w)));

                float2 screenOffset=VToF.uv.xy*_Disortion*_ScreenTex_TexelSize.xy;
                fixed3 texScreen=tex2D(_ScreenTex,VToF.srcPos.xy/VToF.srcPos.w+screenOffset).rgb;

                fixed3 getColor=lerp(texScreen.rgb,texColor,opaque)+getEdge*_EdgeColor;

                return fixed4(getColor,1);
            }

            ENDCG
        }
    }
}

这里的深度计算由于要计算边缘的深度而不是水底的深度,所以将depth取反得到水面边缘的深度值,然后将它乘以一个颜色与最终结果相加,得到一个边缘泛白的效果(如果要实现边缘泡沫的话使用贴图代替这个颜色就行了,但是我们这里是卡通水体,所以就不整那个了,并且使用泡沫的话还需要波浪和纹理偏移叠加才好看,不然真心特别丑)。并且,将贴图按照波浪方向一定规律移动,最终得出一个比较“好看”的卡通水面。

这里我耍了两个花招:

  • 由于原图比较暗,我强行乘以亮度让它亮起来,这个在后处理调色相的时候挺常见。
  • 就像消融效果做的那样,我们获得的颜色减去一个既定的值来获得颜色差,然后使用这个颜色差Opaque来进行水下和水面的插值,如果既定值比较合理,效果还可以比较诡异:

        最近学了一下HLSL,我用HLSL中的语法把第一个水面写了一下,作为参考,以后自己写URP的时候语法不太记得了就抄这段代码就好啦~~


Shader "Hidden/Water"
{
    Properties
    {
        _WaveMap("WaveMap",2D)="white"{}
        _speedX("WaveSpeedX",float)=1.0
        _speedY("WaveSpeedY",float)=1.0
        _Disortion("Disortion",Range(0,100))=10
        _FresnelScale("FresnelScale",Range(0.0,1.0))=1.0
        _Gloss("Gloss",Range(8,100))=10
        _BumpScale("BumpScale",float)=1.0
        _DepthBlend("DepthBlend",float)=1
        _DepthColor("DepthColor",Color)=(0,0,0,0)
    }
    SubShader
    {
        
        Tags
        {
            "RenderPipeline"="UniversalRenderPipline"
            "RenderType"="Transparent"
            "Queue"="Transparent"
        }
        Pass
        {
            HLSLPROGRAM

            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl"
                        #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
            #include "Packages/com.unity.render-pipelines.universal/Shaders/LitInput.hlsl"
            #pragma vertex myVertex
            #pragma fragment myFragment

            //这里的Cbuffer的定义是必须加的,如果报错就不加
            //CBUFFER_START(UnityPerMaterial)
                float4 _DepthColor;
                float _speedX;
                float _speedY;
                float _Disortion;
                float _FresnelScale;
                float _Gloss;
                //float _BumpScale;
                float _DepthBlend;
//
                float4 _CameraColorTexture_TexelSize;
                float4 _WaveMap_ST;
            //CBUFFER_END

            TEXTURE2D(_WaveMap);SAMPLER(sampler_WaveMap);
            //ColorTexture是屏幕图象,Opaque是不透明队列图象
            //注意Sampler必须是小写,大写就错误了
            TEXTURE2D(_CameraOpaqueTexture);SAMPLER(sampler_CameraOpaqueTexture);
            //TEXTURE2D(_CameraDepthTexture);SAMPLER(SAMPLER_CameraDepthTexture);

            struct VertexData
            {
                float4 vertex:POSITION;
                float2 uv:TEXCOORD0;
                float3 normal:NORMAL;
                float3 tangent:TANGENT;
    
            };
    
            struct VertexToFragment
            {
                float4 pos:SV_POSITION;
                float2 WaveUV:TEXCOORD0;
                float3 worldPos:TEXCOORD1;
                float4 srcPos:TEXCOORD2;
                float3 TangentToWorld[3]:TEXCOORD3;
            };
    
            VertexToFragment myVertex(VertexData v)
            {
                VertexToFragment VToF=(VertexToFragment)0;
                VToF.pos=TransformObjectToHClip(v.vertex);
                VToF.srcPos=ComputeScreenPos(VToF.pos);
                VToF.WaveUV=TRANSFORM_TEX(v.uv,_WaveMap);
                VToF.worldPos=TransformObjectToWorld(v.vertex.xyz);
    
                float3 worldNormal=TransformObjectToWorldNormal(v.normal);
                float3 worldTangent=TransformObjectToWorldDir(v.tangent);
                float3 BioNormal=normalize(cross(worldTangent,worldNormal));
                VToF.TangentToWorld[0]=float3(worldTangent.x,BioNormal.x,worldNormal.x);
                VToF.TangentToWorld[1]=float3(worldTangent.y,BioNormal.y,worldNormal.y);
                VToF.TangentToWorld[2]=float3(worldTangent.z,BioNormal.z,worldNormal.z);
    
                return VToF;
            }
    
            half4 myFragment(VertexToFragment VToF):SV_TARGET
            {
                float3x3 TangentToWorld=float3x3(VToF.TangentToWorld[0],VToF.TangentToWorld[1],VToF.TangentToWorld[2]);
    
                float2 speed=_Time.y*float2(_speedX,_speedY);
                float3 tangentNormalA=UnpackNormalScale(SAMPLE_TEXTURE2D(_WaveMap,sampler_WaveMap,VToF.WaveUV+speed),_BumpScale);
                //tangentNormalA.xy*=_BumpScale;
                float3 tangentNormalB=UnpackNormalScale(SAMPLE_TEXTURE2D(_WaveMap,sampler_WaveMap,VToF.WaveUV-speed),_BumpScale);
                //tangentNormalB.xy*=_BumpScale;
                float3 tangentNormal=normalize(tangentNormalA+tangentNormalB);
                float3 worldNormal=normalize(mul(TangentToWorld,tangentNormal));
    
                float2 offset=tangentNormal.xy*_CameraColorTexture_TexelSize.xy*_Disortion;
    
                half2 srcUV=VToF.srcPos.xy/VToF.srcPos.w+offset;
    
                //float4 depthSample=SAMPLE_TEXTURE2D(_CameraDepthTexture,srcUV);
                //float depth=LinearEyeDepth(depthSample);
                //注意这里的深度采样方法,HLSL中是不需要自己指定深度贴图的
                half SceneDepth = SampleSceneDepth(srcUV);
                half depth = LinearEyeDepth(SceneDepth, _ZBufferParams);
                float LinearDepth=saturate(_DepthBlend*(depth-VToF.srcPos.w));
    
                //float3 worldLight=normalize(UnityWorldSpaceLightDir(VToF.worldPos));
                //float3 worldView=normalize(UnityWorldSpaceViewDir(VToF.worldPos));
                //这里很酷,利用光照的结构体,免去了原来一堆宏定义的痛苦
                Light mainLight=GetMainLight();
                float3 worldLight=normalize(mainLight.direction);
                float3 worldView=normalize(GetCameraPositionWS()-VToF.worldPos);

    
                half4 screenAlbedo=SAMPLE_TEXTURE2D(_CameraOpaqueTexture,sampler_CameraOpaqueTexture,srcUV);
                half3 screenWithDepth=lerp(screenAlbedo.rgb,_DepthColor.rgb,LinearDepth);
    
                half3 fresnel=_FresnelScale+pow(1-max(0,dot(worldView,worldNormal)),5);
    
                half3 Diffuse=max(0,dot(worldNormal,worldLight));
                half3 Specular=pow(max(0,dot(worldNormal,normalize(worldLight+worldView))),_Gloss);
    
                half3 finalColor=lerp(screenWithDepth,Diffuse,saturate(fresnel))+Specular;
                return half4(finalColor,1);
            }
            ENDHLSL
        }
    }
}

  • 19
    点赞
  • 66
    收藏
    觉得还不错? 一键收藏
  • 5
    评论
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值