前言

  • 最近算是把shader入门精要给看完了,不得不说这东西还是蛮有帮助的,因为在接触shader前写游戏demo,我总是会对某些地方为什么要进行坐标转换存在亿点小小的疑惑。
  • 本篇博客则是用刚学的shader搞一个水面效果,代码有现成的,但自己重新一步一步写下去的时候还是翻了不少资料+Debug去了解为什么这么写。
  • shader入门知识中,应该就剩后处理,Standard Shader、卡通渲染是没有机会覆盖到的,这些等以后用到再研究。

这里还是想吐槽一下,shader的debug是真的蛋疼,不能断点就算了,打印日志也没有,只能通过draw call+成像判断,对于初学者来说真的有些无解。


效果预览


环境准备

Scenes

  • 总的来说就是如下一个小岛:
    场景预览

    • Plane其实就是水面初始的模样,为了让你知道创建哪个3D Object所以没改名
  • Plane挂载了Water材质:
    挂载材质

    • Water材质挂载了Water.shader,Water.shader是Unity高度封装过的Standard Surface Shader:
    • Standard Surface Shader
  • 最后,不要忘记摄像机选择前向渲染:
    摄像机前向渲染


Lighting设置

  • 主要就是设置了一个奇妙的天空盒用来观察反射效果+HDR,其它保持默认设置不变:
    Lighting

Render Queue(重要)

  • 查看Plane的Render Queue,会发现默认值为2000:
    默认Render Queue
  • 这里先打开Water.shader,通过改Tags的方式更改Render Queue(改的原因下面会说):
    image-1676814598108
  • 返回主界面,再次查看Plane的Render Queue,会发现变成3000了:
    Render Queue=3000

为什么要更改Render Queue?

主要有两个原因:

  1. 水面是半透明物体,必须保证非透明物体渲染完成再到透明/半透明物体渲染,由此也可得知,水下面的物体Render Queue都是<3000的
  2. 当前没有使用延迟渲染,当要获取法线纹理、深度纹理时,需要通过一个单独的Pass渲染得到,具体实现是:使用着色器替换(Shader ReplaceMent)技术过滤要渲染的类型为Opaque且Render Queue<2500

  • 为此,我们可以看到树、石头的Render Queue都<2500:
    树
    石头

之后看深度图的时候会在Frame Debug中看具体影响


代码实现

深浅水区颜色控制

代码 & 调参

  • 当前Water.shader代码如下:
Shader "Custom/Water"
{
    Properties
    {
        _Color ("Color", Color) = (1,1,1,1)
        _MainTex ("MainTex", 2D) = "white" {}
        _WaterShallowColr("WaterShallowColr", Color) = (1,1,1,1)    //浅水区颜色
        _WaterDeepColr("WaterDeepColr", Color) = (1,1,1,1)          //深水区颜色
        _Glossiness ("Smoothness", Range(0,1)) = 0.5
        _Metallic ("Metallic", Range(0,1)) = 0.0
    }
    SubShader
    {
        //透明物体渲染模式
        Tags { "RenderType"="Transparent" "Queue"="Transparent"}
        LOD 200

        //透明物体关闭深度写入
        Zwrite off

        CGPROGRAM

        //vertex:vert   -> 顶点着色器函数
        //noshadow      -> 无阴影,surface shader会自动处理
        //Standard      -> 使用Standard光照模型
        #pragma surface surf Standard vertex:vert alpha noshadow

        #pragma target 3.0

        sampler2D _MainTex;
        //从摄像机获取的深度图,float是为了解决精度问题(可以让水接近在越浅的地方越透明)
        sampler2D_float _CameraDepthTexture;

        struct Input
        {
            float4 proj;
        };

        half _Glossiness;
        half _Metallic;
        fixed4 _Color;
        fixed4 _WaterDeepColr;
        fixed4 _WaterShallowColr;

        UNITY_INSTANCING_BUFFER_START(Props)
        UNITY_INSTANCING_BUFFER_END(Props)

        //顶点着色器
        void vert(inout appdata_full v, out Input i) 
        {
            UNITY_INITIALIZE_OUTPUT(Input, i);
            //ComputeGrabScreenPos 的原理等同于 ComputeScreenPos,只是多了平台的兼容性
            //ComputeScreenPos 得到是未进行齐次除法的屏幕坐标,本质上是把裁剪空间坐标从[-w,w]转成[0,w]
            //之所以不在顶点着色器就进行齐次除法,是因为投影空间并不是线性的,要在片元着色器进行线性插值后再齐次除法才是正确的
            //齐次除法:就是除以w分量
            //原则上只影响xy分量
            i.proj = ComputeGrabScreenPos(UnityObjectToClipPos(v.vertex));
            //z分量转成视角空间下的分量,即从摄像机到顶点的距离
            COMPUTE_EYEDEPTH(i.proj.z);
        }

        void surf (Input IN, inout SurfaceOutputStandard o)
        {
            //tex2Dproj的返回值中深度信息是存在r通道中的,这点可以参考宏:SAMPLE_DEPTH_TEXTURE_PROJ
            half depth = LinearEyeDepth(tex2Dproj(_CameraDepthTexture, UNITY_PROJ_COORD(IN.proj)).r);
            half deltaDepth = depth - IN.proj.z;

            //根据深度值获取颜色插值
            fixed4 c = lerp(_WaterShallowColr, _WaterDeepColr, deltaDepth);
            
            //控制反光、折射等
            o.Albedo = c.rgb;
            o.Metallic = _Metallic;
            o.Smoothness = _Glossiness;
            //控制透明度
            o.Alpha = c.a;
        }
        ENDCG
    }
    FallBack "Diffuse"
}

  • 这时候返回Unity主界面,会发现Plane还是白色的,那是因为我们还没有设置颜色,这里我们把浅水区的颜色设置成B1DBD1,深水区设置成00D4C9,得到的效果如下:
    调整深浅水区的颜色后效果
  • 看起来有点阴森的感觉,这是因为我们用的还是Standard光照模型,在之后我们会修改它的。
  • 下面简单说下原理。

_CameraDepthTexture解析

  • 该参数不用用户自己初始化,它来源于摄像机的深度纹理,会自动传入的,正常来说还要用脚本开启深度纹理的,但至少的Windows平台上不需要,所以这里就没加了。
  • 我可以看看深度纹理长啥样吗?可以,理论上FrameDebug可以直接看到,但如果我们现在不经过任何调整就去看,只会得到一片漆黑,如下所示:
    深度纹理
  • 产生这个问题的原因在于摄像机的近平面和远平面设置得有些过大了,导致离摄像机近得物体深度值非常小,调整参数后(刚好覆盖场景)就可以看到:
    摄像机近远平面参数
    深度纹理
  • 当然,现在这么做只是为了展现出深度纹理后便于调试,实际上就算不改,保持黑屏的深度纹理也并不会影响其正常运作,只是人的肉眼看不出来罢了。

  • 为什么场景是红色的?这是因为深度值只存储在R通道中,代码中只取了R通道也说明了这一点;深度值从0到1代表的是从远到近,所以越近的物体越红。

更多深度纹理相关的,参考这里,里面的参考资料也可以看下,基本是搬运的。


  • 还有另外一个需要注意的并且非常重要的问题,就是文章开始说到的Render Queue问题,这张深度纹理中其实不包含Plane的信息,因为Plane的值是>2500的,所以不会被计算到深度纹理中。
  • 我们可以把地面的Active设置为False(Plane保留),然后再查看深度纹理:
    去掉地面后的深度纹理
  • 又或者不去掉地面,你在深度纹理draw call中也找不到Plane的渲染,只是去掉地面后能更“可视化”的反映该问题。
  • 了解这个前提,才能懂后面的函数做了什么。

ComputeGrabScreenPos解析

  • ComputeGrabScreenPos本质上就是ComputeScreenPos,只是在此基础上做了更多平台性的兼容,下面我们来看看ComputeScreenPos的CG代码:
    ComputeScreenPos CG代码
  • 代码可以进一步剔除目前不会触发的宏,简化后如下:
    float4 o = pos * 0.5f;
    //_ProjectionParams是投影参数,x返回1表示投影矩阵没有翻转,不需要变;返回-1则表示投影矩阵被翻转了,此时按y轴翻转
    //一般_ProjectionParams.x都返回1,除非你做了特殊操作把投影矩阵翻转了
    o.xy = float2(o.x, o.y*_ProjectionParams.x) + o.w;
    //还原zw
    o.zw = pos.zw;
    return o;
  • 这看起来跟平常接触到的公式的公式似乎不太一样?实际上是一样的,只不过进行了一部分约简。
  • 常规的齐次除法+屏幕映射的公式如下:

    screenx=clipxpixelWidth2clipw+pixelWidth2screen_x = \frac{clip_x * pixelWidth}{2 * clip_w} + \frac{pixelWidth}{2}

    screeny=clipypixelHeigth2clipw+pixelHeigth2screen_y = \frac{clip_y * pixelHeigth}{2 * clip_w} + \frac{pixelHeigth}{2}

OpenGL中,左下角坐标为(0, 0),右上角坐标则是(pixelWidth, pixelHeigth)

  • 因为y基本同理于x,所以下面只拿x做变换,首先是第一次变换:

    screenx=(0.5clipxclipw+0.5)pixelWidthscreen_x = ( 0.5 * \frac{clip_x}{clip_w}+ 0.5) * pixelWidth

  • 接着两边都乘以clip_w,就得到最终公式了:

    screenxclipw=(0.5clipx+0.5clipw)pixelWidthscreen_x * clip_w = (0.5 * clip_x+ 0.5 * clip_w) * pixelWidth

  • 到这里为止,其实就跟CG代码基本一致了,只是没在这里计入pixelWidth
  • 如果还看不出来,那么可以把CG中的float4 o = pos * 0.5f;拆成o.x = pos.x * 0.5fo.w = pos.w * 0.5f来看
  • 然后o.xy = float2(o.x, o.y*_ProjectionParams.x) + o.w;剔除y,变o.x = o.x + o.w;再把o.x = pos.x * 0.5f o.w = pos.w * 0.5f代入
  • 最终变成o.x = pos.x * 0.5f + pos.w * 0.5f,在需要时再乘上pixelWidth,就一样了:

    o.x=(pos.x0.5f+pos.w0.5f)pixelWidtho.x = (pos.x * 0.5f + pos.w * 0.5f) * pixelWidth

  • 从上面式子中的screen_x * clip_w也可以看出,这其实还是齐次空间下的坐标,要除以w分量才是真正的屏幕坐标。

LinearEyeDepth和tex2Dproj函数在下面添加透明度代码后再说,因为这样子更好调试+容易理解


透明度 & 深度关联

  • 更改后效果如下:

高光来自地面材质,非水面

  • 代码核心变更点:
    • 添加了透明度属性_TranAmount
    • 添加了相对深度属性_DepthRange
    • 颜色插值因子由min(_DepthRange, deltaDepth)/_DepthRange)决定
  • 变更后代码如下:
Shader "Custom/Water"
{
    Properties
    {
        _Color ("Color", Color) = (1,1,1,1)
        _MainTex ("MainTex", 2D) = "white" {}
        _WaterShallowColr("WaterShallowColr", Color) = (1,1,1,1)    //浅水区颜色
        _WaterDeepColr("WaterDeepColr", Color) = (1,1,1,1)          //深水区颜色
        _TranAmount("TranAmount",Range(0,1)) = 0.5                  //透明度
        _DepthRange("DepthRange",Range(0.1,100)) = 1                //相对深度范围,值越大,同样的深度就显得越浅
        _Glossiness ("Smoothness", Range(0,1)) = 0.5
        _Metallic ("Metallic", Range(0,1)) = 0.0
    }
    SubShader
    {
        //透明物体渲染模式
        Tags { "RenderType"="Transparent" "Queue"="Transparent"}
        LOD 200

        //透明物体关闭深度写入
        Zwrite off

        CGPROGRAM

        //vertex:vert   -> 顶点着色器函数
        //noshadow      -> 无阴影,surface shader会自动处理
        //Standard      -> 使用Standard光照模型
        #pragma surface surf Standard vertex:vert alpha noshadow

        #pragma target 3.0

        sampler2D _MainTex;
        //从摄像机获取的深度图,float是为了解决精度问题(可以让水接近在越浅的地方越透明)
        sampler2D_float _CameraDepthTexture;

        struct Input
        {
            float4 proj;
        };

        half _Glossiness;
        half _Metallic;
        fixed4 _Color;
        fixed4 _WaterDeepColr;
        fixed4 _WaterShallowColr;
        half _TranAmount;
        half _DepthRange;

        UNITY_INSTANCING_BUFFER_START(Props)
        UNITY_INSTANCING_BUFFER_END(Props)

        //顶点着色器
        void vert(inout appdata_full v, out Input i) 
        {
            UNITY_INITIALIZE_OUTPUT(Input, i);
            //ComputeGrabScreenPos 的原理等同于 ComputeScreenPos,只是多了平台的兼容性
            //ComputeScreenPos 得到是未进行齐次除法的屏幕坐标,本质上是把裁剪空间坐标从[-w,w]转成[0,w]
            //之所以不在顶点着色器就进行齐次除法,是因为投影空间并不是线性的,要在片元着色器进行线性插值后再齐次除法才是正确的
            //齐次除法:就是除以w分量
            //原则上只影响xy分量
            i.proj = ComputeGrabScreenPos(UnityObjectToClipPos(v.vertex));
            //z分量转成视角空间下的分量,即从摄像机到顶点的距离
           COMPUTE_EYEDEPTH(i.proj.z);
        }

        void surf (Input IN, inout SurfaceOutputStandard o)
        {
            //tex2Dproj的返回值中深度信息是存在r通道中的,这点可以参考宏:SAMPLE_DEPTH_TEXTURE_PROJ
            half depth = LinearEyeDepth(tex2Dproj(_CameraDepthTexture, UNITY_PROJ_COORD(IN.proj)).r);
            half deltaDepth = depth - IN.proj.z;

            //根据深度值获取颜色插值
            fixed4 c = lerp(_WaterShallowColr, _WaterDeepColr, min(_DepthRange, deltaDepth)/_DepthRange);
            
            //控制反光、折射等
            o.Albedo = c.rgb;
            o.Metallic = _Metallic;
            o.Smoothness = _Glossiness;
            //控制透明度
            o.Alpha = c.a * _TranAmount;
        }
        ENDCG
    }
    FallBack "Diffuse"
}
  • OK,现在有了透明度之后可以更方便的说明代码中的depth是什么了。

tex2Dproj解析

  • 找到CG代码中的定义,发现本质上还是tex2D采集纹理,只是采集的坐标做了齐次除法,得到了真正的屏幕坐标:
float4 tex2Dproj(sampler2D s, in float4 t) {
    return tex2D(s, t.xy / t.w); 
}
  • 因为采集的是深度纹理_CameraDepthTexture,所以得到的uv坐标是表示深度的,深度值存在r通道中(从之前的深度纹理中也可以看出这一点)

LinearEyeDepth解析

  • 在上面的tex2Dproj中我们已经获取到一个深度值了,那LinearEyeDepth还有什么用呢?这其实跟我们使用透视投影有关。
  • 透视投影中,当坐标系从观察空间变为裁剪空间时,需要乘以投影矩阵,过程->结果大概如下所示:

    =cotFOV2Aspect0000cotFOV20000Far+NearFarNear2NearFarFarNear0010xvyvzv1= \left|\begin{matrix} \frac{cot\frac{FOV}{2}}{Aspect} & 0 & 0 & 0 \\ 0 & cot\frac{FOV}{2} & 0 & 0 \\ 0 & 0 & \frac{Far + Near}{Far - Near} & -\frac{2 * Near * Far}{Far-Near} \\ 0 & 0 & -1 & 0 \end{matrix} \right| * \left|\begin{matrix} x_v \\ y_v \\ z_v \\ 1 \end{matrix} \right|

    =xvcotFOV2AspectyvcotFOV2zvFar+NearFarNear2NearFarFarNearzv= \left|\begin{matrix} x_v\frac{cot\frac{FOV}{2}}{Aspect} \\ y_vcot\frac{FOV}{2} \\ -z_v\frac{Far+Near}{Far-Near} - \frac{2 * Near * Far}{Far-Near} \\ -z_v \end{matrix} \right|

v下标表示观察空间坐标系

  • 更近一步的,进行齐次除法后,就可以得到ndc的坐标系,由于我们目前只关注z、w分量,所以单独提取出来后,如下所示:

    zc=zvFar+NearFarNear2NearFarFarNearz_c = -z_v\frac{Far + Near}{Far - Near} - \frac{2 * Near * Far}{Far - Near}

    wc=zvw_c = -z_v

c下标表示裁剪空间坐标系

zn=zcwc=Far+NearFarNear+2NearFar(FarNear)zvz_n = \frac{z_c}{w_c} = \frac{Far + Near}{Far - Near} + \frac{2 * Near * Far}{(Far-Near) * z_v}

n下表表示NDC坐标系

  • 稍微解释下参数,Near、Far、FOV都是摄像机可获取参数:
    Near、Far、FOV
  • Aspect是近远平面横纵比,在此之前我们需要先获取nearClipPlaneHeight、farClipPlaneHeight、nearClipPlaneWidth、farClipPlaneWidth,首先是前2者:

    nearClipPlaneHeight=2NeartanFOV2nearClipPlaneHeight = 2 * Near * tan\frac{FOV}{2}

    farClipPlaneHeight=2FartanFOV2farClipPlaneHeight = 2 * Far * tan\frac{FOV}{2}

  • 至于后2者nearClipPlaneWidth、farClipPlaneWidth则分别对应摄像机的W、H参数:
    摄像机W、H
  • 然后Aspect的两个等式如下:

    Aspect=nearClipPlaneWidthnearClipPlaneHeightAspect =\frac{nearClipPlaneWidth}{nearClipPlaneHeight}

    Aspect=farClipPlaneWidthfarClipPlaneHeightAspect =\frac{farClipPlaneWidth}{farClipPlaneHeight}

  • 最终可以确定的一点是,透视投影的变换过程,由于投影矩阵是非线性的,所以得出的变化结果依旧是非线性(不满足x’=Mx),然而我们的计算过程通常需要线性的深度值,so我们需要把目前从非线性空间里获取到的z值重新变换到线性空间下。
  • 变换的过程其实就是倒推,重新变换到观察空间(也叫视角空间下)。
  • 不过在倒推之前我们还需要知道这么个东西:由于Unity(遵循OpenGL的一些规范),最终会把NDC中z分量(范围是在[-1, 1])存在图像中(z分量范围[0, 1]),所以中间会进行如下转换:

    d=0.5zn+0.5d = 0.5 * z_n + 0.5

  • 在倒推过程中,我们不能直接使用获取到的z值,因为从图像中采样出来的实际上是d值,我们应该把上面的等式经过移项后,再代入到原来的公式里计算,至于倒推过程则不再赘述,实际上就是把上面求NDC的公式移下项。
  • 说了这么多复杂的东西,此时此刻的你应该明白LinearEyeDepth的作用了,LinearEyeDepth其实就是帮我们做了这个倒推的过程,把原本非线性的z重新转换成线性的,这样才能用来做下一步计算

depth、deltaDepth、IN.proj.z是什么

  • 先说结果:
    • depth是摄像机到水底的距离
    • IN.proj.z就是摄像机到水面的距离
    • deltaDepth就是depth-IN.proj.z即水面和水底的距离
  • 如下图所示:
    图解
  • 初次接触这个代码时,对于IN.proj.z应该没什么疑问,但depth怎么来可能就比较疑惑了,为什么上图中的红线没有在水面停下而是到水底才停下?
  • 要解答这个问题就需要回顾上面讲得深度纹理了(忘记的回去看下),原因就在于水面的根本就没有存在于深度纹理中,tex2Dproj采样就是深度纹理中的地面,所以不存在被水面拦截的情况。

水面波浪效果

单法线移动

  • 波浪效果核心要做的事情可以拆分成2件事:法线制造凹凸感+法线移动形成波浪
  • 下面是把法线添加到Plane上,且让法线根据运行时间产生”移动“的效果:
  • 尽管效果还不太好,但还是先看看更改后的代码部分:
Shader "Custom/Water"
{
    Properties
    {
        //略...
        _NormalTex("Normal",2D) = "bump"{}                          //法线贴图
        _WaterSpeed("WaterSpeed" ,float) = 1                        //水流速度
    }
    SubShader
    {
        //略...

        struct Input
        {
            float4 proj;
            float2 uv_NormalTex;
        };

        //略...
        half _WaterSpeed;
        sampler2D _NormalTex;
        //略...

        void vert(inout appdata_full v, out Input i) 
        {
            //略...
        }

        void surf (Input IN, inout SurfaceOutputStandard o)
        {
            //略...
            float4 bumpColor1 = tex2D(_NormalTex, IN.uv_NormalTex + float2(_WaterSpeed * _Time.x, 0));
            float3 normal = UnpackNormal(bumpColor1).xyz;
            
            o.Normal = normal;
            //略...
        }
        ENDCG
    }
    FallBack "Diffuse"
}

  • 可以看到目前的效果看上去并不太好,这是因为一张小小的法线贴图,放在这么大的湖面上,法线密度太稀疏了
  • 我们需要让法线更密集+混乱,改动部分的代码如下:
Shader "Custom/Water"
{
    Properties
    {
        //略...
        _Refract("Refract",float) = 0.1                             //法线密集程度(绝对值越大越密)
    }
    SubShader
    {
        //略...
        
        half _Refract;
        //略...

        void surf (Input IN, inout SurfaceOutputStandard o)
        {
            //略...
            float4 bumpColor1 = tex2D(_NormalTex, IN.uv_NormalTex + float2(_WaterSpeed * _Time.x, 0));
            float2 offset = UnpackNormal(bumpColor1).xy * _Refract;
            bumpColor1 = tex2D(_NormalTex, IN.uv_NormalTex + offset + float2(_WaterSpeed * _Time.x, 0));
            float3 normal = UnpackNormal(bumpColor1).xyz;
            
            o.Normal = normal;
            //略...
        }
        ENDCG
    }
    FallBack "Diffuse"
}

  • 效果如下:
  • 现在看起来稍微有那么一点点感觉了,但整体来说还有以下2个问题:
    1. 流动方向太单一了,一眼就看出来是贴图在位移,不像是无规则的流动
    2. 质感看上去不像水,更像是类似水银的金属物质在流动
  • 关于第2点,如果你有这种感觉,那说明Standard模型还是挺成功的,因为其本身就是一个包含金属质感的光照模型;至于第一点,解决起来也比较简单,我们对法线贴图进行某种”混合”即可,下面我们就做这一点。

双法线融合

  • 上面单张法线贴图看起来很容易穿帮,那我们就使用两张法线贴图进行混合,一张横向移动,一张纵向移动,再配合贴图采样偏移,就可以达到目的,以下是代码核心改动部分:
//略...
float4 bumpOffset1 = tex2D(_NormalTex, IN.uv_NormalTex + float2(_WaterSpeed * _Time.x, 0));
float4 bumpOffset2 = tex2D(_NormalTex, float2(1 - IN.uv_NormalTex.y, IN.uv_NormalTex.x) + float2(_WaterSpeed * _Time.x, 0));
float4 offsetColor = (bumpOffset1 + bumpOffset2) / 2;
float2 offset = UnpackNormal(offsetColor).xy * _Refract;
float4 bumpColor1 = tex2D(_NormalTex, IN.uv_NormalTex + offset + float2(_WaterSpeed * _Time.x, 0));
float4 bumpColor2 = tex2D(_NormalTex, float2(1 - IN.uv_NormalTex.y, IN.uv_NormalTex.x) + offset + float2(_WaterSpeed * _Time.x, 0));
float3 normal = UnpackNormal((bumpColor1 + bumpColor2) / 2).xyz;
//略...
  • 实际效果如下,如果没有增加法线密度则能比较明显的看出是两张贴图分别在横纵向移动,增加法线密度后能较好的掩盖这一点:

光照 & 透明调整

  • 上面解决了流动方向过于单一的问题,接下来就解决水质感的问题,要解决这个问题我们就需要对光照模型和透明度做一些调整,调整完后效果如下:
  • 光照模型是基于BlinnPhong模型改的(改没改看不出太大区别,可以调小WaterSpeed观察),并且加了高光,我顺便把源文件的BlinnPhong模型的CG代码同步属性名后放在下面了,这里再次贴出完整代码:
Shader "Custom/Water"
{
    Properties
    {
        _Color ("Color", Color) = (1,1,1,1)
        _WaterShallowColr("WaterShallowColr", Color) = (1,1,1,1)    //浅水区颜色
        _WaterDeepColr("WaterDeepColr", Color) = (1,1,1,1)          //深水区颜色
        _TranAmount("TranAmount",Range(0,100)) = 0.5                //透明度
        _DepthRange("DepthRange",Range(0.1,100)) = 1                //相对深度范围,值越大,同样的深度就显得越浅
        _NormalTex("Normal",2D) = "bump"{}                          //法线贴图
        _WaterSpeed("WaterSpeed" ,float) = 1                        //水流速度
        _Refract("Refract",float) = 0.1                             //法线密集程度(绝对值越大越密)
        _Specular("Specular",float) = 1                             //控制高光,越小高光越明显
        _Gloss("Gloss", float)=0.5                                  //高光范围 越大高光越明显
        _SpecularColor("SpecularColor", Color) = (1,1,1,1)          //高光颜色值
    }
    SubShader
    {
        //透明物体渲染模式
        Tags { "RenderType"="Transparent" "Queue"="Transparent"}
        LOD 200

        //透明物体关闭深度写入
        Zwrite off

        CGPROGRAM

        //vertex:vert   -> 顶点着色器函数
        //noshadow      -> 无阴影,surface shader会自动处理
        //WaterLight    -> 使用自定义WaterLight光照模型
        #pragma surface surf WaterLight vertex:vert alpha noshadow

        #pragma target 3.0

        //从摄像机获取的深度图,float是为了解决精度问题(可以让水接近在越浅的地方越透明)
        sampler2D_float _CameraDepthTexture;
        sampler2D _NormalTex;

        struct Input
        {
            float4 proj;
            float2 uv_NormalTex;
        };

        fixed4 _Color;
        fixed4 _WaterDeepColr;
        fixed4 _WaterShallowColr;
        half _TranAmount;
        half _DepthRange;
        half _WaterSpeed;
        half _Refract;
        half _Specular;
        half _Gloss;
        fixed4 _SpecularColor;

        UNITY_INSTANCING_BUFFER_START(Props)
        UNITY_INSTANCING_BUFFER_END(Props)

        fixed4 LightingWaterLight(SurfaceOutput s, fixed3 lightDir, half3 viewDir, fixed atten) 
        {
            half3 h = normalize(lightDir + viewDir);
            float diff = max(0, dot(normalize(lightDir), s.Normal));
            float nh = max(0, dot(h, s.Normal));
            float spec = pow(nh, s.Specular * 128) * s.Gloss;
            fixed4 c;
            c.rgb = (s.Albedo * _LightColor0.rgb * diff + _LightColor0.rgb * _SpecularColor.rgb * spec) * atten;
            c.a = s.Alpha + spec * _SpecularColor.a;

            //原BlinnPhong
            //half3 h = normalize(lightDir + viewDir);
            //float diff = max(0, dot(s.Normal, normalize(lightDir)));
            //float nh = max(0, dot(s.Normal, h));
            //float spec = pow(nh, s.Specular * 128) * s.Gloss;
            //fixed4 c;
            //c.rgb = s.Albedo * _LightColor0.rgb * diff + _LightColor0.rgb * _SpecularColor.rgb * spec;
            //c.a = s.Alpha;

            return c;
        }

        //顶点着色器
        void vert(inout appdata_full v, out Input i) 
       {
            UNITY_INITIALIZE_OUTPUT(Input, i);
            //ComputeGrabScreenPos 的原理等同于 ComputeScreenPos,只是多了平台的兼容性
            //ComputeScreenPos 得到是未进行齐次除法的屏幕坐标,本质上是把裁剪空间坐标从[-w,w]转成[0,w]
            //之所以不在顶点着色器就进行齐次除法,是因为投影空间并不是线性的,要在片元着色器进行线性插值后再齐次除法才是正确的
            //齐次除法:就是除以w分量
            //原则上只影响xy分量
            i.proj = ComputeGrabScreenPos(UnityObjectToClipPos(v.vertex));
            //z分量转成视角空间下的分量,即从摄像机到顶点的距离
            COMPUTE_EYEDEPTH(i.proj.z);
       }

        void surf (Input IN, inout SurfaceOutput o)
        {
            //tex2Dproj的返回值中深度信息是存在r通道中的,这点可以参考宏:SAMPLE_DEPTH_TEXTURE_PROJ
            half depth = LinearEyeDepth(tex2Dproj(_CameraDepthTexture, UNITY_PROJ_COORD(IN.proj)).r);
            half deltaDepth = depth - IN.proj.z;

            //根据深度值获取颜色插值
            fixed4 c = lerp(_WaterShallowColr, _WaterDeepColr, min(_DepthRange, deltaDepth)/_DepthRange);

            float4 bumpOffset1 = tex2D(_NormalTex, IN.uv_NormalTex + float2(_WaterSpeed * _Time.x, 0));
            float4 bumpOffset2 = tex2D(_NormalTex, float2(1 - IN.uv_NormalTex.y, IN.uv_NormalTex.x) + float2(_WaterSpeed * _Time.x, 0));
            float4 offsetColor = (bumpOffset1 + bumpOffset2) / 2;
            float2 offset = UnpackNormal(offsetColor).xy * _Refract;
            float4 bumpColor1 = tex2D(_NormalTex, IN.uv_NormalTex + offset + float2(_WaterSpeed * _Time.x, 0));
            float4 bumpColor2 = tex2D(_NormalTex, float2(1 - IN.uv_NormalTex.y, IN.uv_NormalTex.x) + offset + float2(_WaterSpeed * _Time.x, 0));
            float3 normal = UnpackNormal((bumpColor1 + bumpColor2) / 2).xyz;
            
            o.Normal = normal;
            //控制反光、折射等
            o.Albedo = c.rgb;
            o.Gloss = _Gloss;
            o.Specular = _Specular;
            //控制透明度
            o.Alpha = min(_TranAmount, deltaDepth) / _TranAmount;
        }
        ENDCG
    }
    FallBack "Diffuse"
}

光照这里在公式已知的情况下就没啥变换了,基本是照搬Unity内部CG的LightingBlinnPhong


岸边波纹

注意:对比原工程更改了波浪纹理、噪声纹理

  • 岸边波浪的实现核心依旧是双重叠加,为了让动画循环则使用sin函数控制。
  • 叠加两层纹理的目的是为了实现波浪一上一下的效果,为了错开纹理使用了_WaveDelta控制,极简易图如下所示:
    一张波浪纹理向岸边推进时,另一张则缩回去
  • 先看最终的实现效果,如下视频所示:
  • 上面视频中还有一个小缺陷,即摄像机靠近时产生的阴影有些碍事,这里一个解决方案就是调小摄像机看到阴影的阈值,设置项如下所示(默认值是150)
    调整Shadow Distance

进调试时觉得碍事时用,不然影响全局阴影

  • 调整后效果如下所示:
  • 核心改动代码如下:
Shader "Custom/Water"
{
    Properties
    {
        //略...
        _WaveTex("WaveTex",2D) = "white"{}                          //岸边波纹纹理
        _NoiseTex("NoiseTex",2D) = "white"{}                        //噪声纹理
        _WaveSpeed("WaveSpeed",float) = 1                           //波纹速度
        _WaveRange("WaveRange",float) = 0.5                         //跟sin函数一起控制纹理偏移,值越大,相同时间内上下的波纹条数越多
        _WaveRangeA("WaveRangeA",float) = 1                         //控制岸边波纹出现深度、波纹间隔
        _WaveDelta("WaveDelta",float) = 0.5                         //第二个波纹的“延迟”(其实就是offset)
    }
    SubShader
    {
        //略...
        sampler2D _NormalTex;
        sampler2D _WaveTex;
        sampler2D _NoiseTex;

        struct Input
        {
            //略
            float2 uv_WaveTex;
            float2 uv_NoiseTex;
        };

        //略...
        float _WaveSpeed;
        float _WaveRange;
        float _WaveRangeA;
        float _WaveDelta;

        void surf (Input IN, inout SurfaceOutput o)
        {
            //略...
            //岸边波纹
            half waveB = 1 - min(_WaveRangeA, deltaDepth) / _WaveRangeA;
            fixed4 noiserColor = tex2D(_NoiseTex, IN.uv_NoiseTex);
            fixed4 waveColor = tex2D(_WaveTex, float2(waveB + _WaveRange * sin(_Time.x * _WaveSpeed + noiserColor.r), 1) + offset);
            waveColor.rgb *= (1 - (sin(_Time.x * _WaveSpeed + noiserColor.r) + 1) / 2) * noiserColor.r;
            fixed4 waveColor2 = tex2D(_WaveTex, float2(waveB + _WaveRange * sin(_Time.x * _WaveSpeed + _WaveDelta + noiserColor.r), 1) + offset);
            waveColor2.rgb *= (1 - (sin(_Time.x * _WaveSpeed + _WaveDelta + noiserColor.r) + 1) / 2) * noiserColor.r;

            //略...
            o.Albedo = c + (waveColor.rgb + waveColor2.rgb) * waveB;
            //略...
        }
        ENDCG
    }
    FallBack "Diffuse"
}

折射 & 反射

一些前置知识

先说一些前置知识:

  • 实现折射和反射融合所使用的是菲涅耳反射(Fresnel reflection)定理,而菲涅耳反射的近似公式有两种:
    1. Schlick菲涅耳近似公式(本次使用),如下所示:

      FSchlick(v,n)=F0+(1F0)(1vn)5F_{Schlick}(v, n) = F_0 + (1 - F_0)(1-v \cdot n) ^ 5

    2. Empricial菲涅耳近似公式,如下所示:

      FEmpricial(v,n)=max(0,min(1,bias+scale(1vn)power))F_{Empricial}(v, n) = max(0, min(1, bias + scale * (1 - v \cdot n)^{power}))

补充:F0为反射系数控制反射强度;v为视角方向,n为表面法线;bias、scale、power都是控制型参数。

  • 关于抓屏:
    1. 折射的抓屏使用的是GrabPass,它要设置在透明的渲染通道中,而我们的水面本身就设置了"RenderType"="Transparent",所以直接放进去即可,之后再进行计算偏移+采样。
    2. 反射的抓屏则是用CubeMap,方式是先对静态物体进行烘焙,然后基于这张纹理之后再进行计算偏移+采样。

Reflection Probe Bake

  • 不同于GrabPass只要再shader中声明就可以从上下文中获取,CubeMap的纹理则是要自己烘焙(Bake)的,烘焙的方式也很简单,我们首先在场景中创建一个Reflection Probe:
    创建Reflection Probe
  • 并且坐标调整到合适位置:
    坐标调整到合适位置
  • 我们还要设置石头、地面、树为静态,之后再点击Bake:
    Bake
  • 如果是第一次进行该场景的烘焙,时间会比较长(取决于CPU配置)且占用大量CPU资源,如下图所示:
    Job
    CPU占用
  • 在烘焙过程中,我们可以把代码改完,因为这是最后一次改动代码了,所以直接贴出全量的:
Shader "Custom/Water"
{
    Properties
    {
        _Color ("Color", Color) = (1,1,1,1)
        _WaterShallowColr("WaterShallowColr", Color) = (1,1,1,1)    //浅水区颜色
        _WaterDeepColr("WaterDeepColr", Color) = (1,1,1,1)          //深水区颜色
        _TranAmount("TranAmount",Range(0,100)) = 0.5                //透明度
        _DepthRange("DepthRange",Range(0.1,100)) = 1                //相对深度范围,值越大,同样的深度就显得越浅
        _NormalTex("Normal",2D) = "bump"{}                          //法线贴图
        _WaterSpeed("WaterSpeed" ,float) = 1                        //水流速度
        _Refract("Refract",float) = 0.1                             //法线密集程度(绝对值越大越密)
        _WaveTex("WaveTex",2D) = "white"{}                          //岸边波纹纹理
        _NoiseTex("NoiseTex",2D) = "white"{}                        //噪声纹理
        _WaveSpeed("WaveSpeed",float) = 1                           //波纹速度
        _WaveRange("WaveRange",float) = 0.5                         //跟sin函数一起控制纹理偏移,值越大,相同时间内上下的波纹条数越多
        _WaveRangeA("WaveRangeA",float) = 1                         //控制岸边波纹出现深度、波纹间隔
        _WaveDelta("WaveDelta",float) = 0.5                         //第二个波纹的“延迟”(其实就是offset)
        _Specular("Specular",float) = 1                             //控制高光,越小高光越明显
        _Gloss("Gloss", float)=0.5                                  //高光范围 越大高光越明显
        _SpecularColor("SpecularColor", Color) = (1,1,1,1)          //高光颜色值
        _Distortion("Distortion",float) = 0.5                       //折射时图像的扭曲程度
        _Cubemap("Cubemap",Cube) = "_Skybox"{}                      //CubeMap纹理
        _FresnelScale("Fresnel",Range(0,1)) = 0.5                   //公式中的F0,控制反射系数
    }
    SubShader
    {
        //透明物体渲染模式
        Tags { "RenderType"="Transparent" "Queue"="Transparent"}
        LOD 200

        //抓屏
        GrabPass{"GrabPass"}

        //透明物体关闭深度写入
        Zwrite off

        CGPROGRAM

        //vertex:vert   -> 顶点着色器函数
        //noshadow      -> 无阴影,surface shader会自动处理
        //WaterLight    -> 使用自定义WaterLight光照模型
        #pragma surface surf WaterLight vertex:vert alpha noshadow

        #pragma target 3.0

        //从摄像机获取的深度图,float是为了解决精度问题(可以让水接近在越浅的地方越透明)
        sampler2D_float _CameraDepthTexture;
        sampler2D _NormalTex;
        sampler2D _WaveTex;
        sampler2D _NoiseTex;
        sampler2D GrabPass;
        float4 GrabPass_TexelSize;
        samplerCUBE _Cubemap;

        struct Input
        {
            float4 proj;
            float2 uv_NormalTex;
            float2 uv_WaveTex;
            float2 uv_NoiseTex;
            float3 worldRefl;
            float3 viewDir;
            float3 worldNormal; 
            //开放的cg里面没找到实现,详细参考官方手册:https://docs.unity3d.com/Manual/30_search.html?q=INTERNAL_DATA
            //主要作用就是搭配WorldNormalVector使用,WorldNormalVector可以获取经过法线贴图修改后的平面的法线信息(世界空间)
            INTERNAL_DATA
        };

        fixed4 _Color;
        fixed4 _WaterDeepColr;
        fixed4 _WaterShallowColr;
        half _TranAmount;
        half _DepthRange;
        half _WaterSpeed;
        half _Refract;
        float _WaveSpeed;
        float _WaveRange;
        float _WaveRangeA;
        float _WaveDelta;
        half _Specular;
        half _Gloss;
        fixed4 _SpecularColor;
        float _Distortion;
        float _FresnelScale;

        UNITY_INSTANCING_BUFFER_START(Props)
        UNITY_INSTANCING_BUFFER_END(Props)

        fixed4 LightingWaterLight(SurfaceOutput s, fixed3 lightDir, half3 viewDir, fixed atten) 
        {
            half3 h = normalize(lightDir + viewDir);
            float diff = max(0, dot(normalize(lightDir), s.Normal));
            float nh = max(0, dot(h, s.Normal));
            float spec = pow(nh, s.Specular * 128) * s.Gloss;
            fixed4 c;
            c.rgb = (s.Albedo * _LightColor0.rgb * diff + _LightColor0.rgb * _SpecularColor.rgb * spec) * atten;
            c.a = s.Alpha + spec * _SpecularColor.a;

            //原BlinnPhong
            //half3 h = normalize(lightDir + viewDir);
            //float diff = max(0, dot(s.Normal, normalize(lightDir)));
            //float nh = max(0, dot(s.Normal, h));
            //float spec = pow(nh, s.Specular * 128) * s.Gloss;
            //fixed4 c;
            //c.rgb = s.Albedo * _LightColor0.rgb * diff + _LightColor0.rgb * _SpecularColor.rgb * spec;
            //c.a = s.Alpha;

            return c;
        }

        //顶点着色器
        void vert(inout appdata_full v, out Input i) 
        {
            UNITY_INITIALIZE_OUTPUT(Input, i);
            //ComputeGrabScreenPos 的原理等同于 ComputeScreenPos,只是多了平台的兼容性
            //ComputeScreenPos 得到是未进行齐次除法的屏幕坐标,本质上是把裁剪空间坐标从[-w,w]转成[0,w]
            //之所以不在顶点着色器就进行齐次除法,是因为投影空间并不是线性的,要在片元着色器进行线性插值后再齐次除法才是正确的
            //齐次除法:就是除以w分量
            //原则上只影响xy分量
            i.proj = ComputeGrabScreenPos(UnityObjectToClipPos(v.vertex));
            //z分量转成视角空间下的分量,即从摄像机到顶点的距离
            COMPUTE_EYEDEPTH(i.proj.z);
        }

        void surf (Input IN, inout SurfaceOutput o)
        {
            //tex2Dproj的返回值中深度信息是存在r通道中的,这点可以参考宏:SAMPLE_DEPTH_TEXTURE_PROJ
            half depth = LinearEyeDepth(tex2Dproj(_CameraDepthTexture, UNITY_PROJ_COORD(IN.proj)).r);
            half deltaDepth = depth - IN.proj.z;

            //根据深度值获取颜色插值
            fixed4 c = lerp(_WaterShallowColr, _WaterDeepColr, min(_DepthRange, deltaDepth)/_DepthRange);

            //水面波浪
            float4 bumpOffset1 = tex2D(_NormalTex, IN.uv_NormalTex + float2(_WaterSpeed * _Time.x, 0));
            float4 bumpOffset2 = tex2D(_NormalTex, float2(1 - IN.uv_NormalTex.y, IN.uv_NormalTex.x) + float2(_WaterSpeed * _Time.x, 0));
            float4 offsetColor = (bumpOffset1 + bumpOffset2) / 2;
            float2 offset = UnpackNormal(offsetColor).xy * _Refract;
            float4 bumpColor1 = tex2D(_NormalTex, IN.uv_NormalTex + offset + float2(_WaterSpeed * _Time.x, 0));
            float4 bumpColor2 = tex2D(_NormalTex, float2(1 - IN.uv_NormalTex.y, IN.uv_NormalTex.x) + offset + float2(_WaterSpeed * _Time.x, 0));
            float3 normal = UnpackNormal((bumpColor1 + bumpColor2) / 2).xyz;

            //岸边波纹
            half waveB = 1 - min(_WaveRangeA, deltaDepth) / _WaveRangeA;
            fixed4 noiserColor = tex2D(_NoiseTex, IN.uv_NoiseTex);
            fixed4 waveColor = tex2D(_WaveTex, float2(waveB + _WaveRange * sin(_Time.x * _WaveSpeed + noiserColor.r), 1) + offset);
            waveColor.rgb *= (1 - (sin(_Time.x * _WaveSpeed + noiserColor.r) + 1) / 2) * noiserColor.r;
            fixed4 waveColor2 = tex2D(_WaveTex, float2(waveB + _WaveRange * sin(_Time.x * _WaveSpeed + _WaveDelta + noiserColor.r), 1) + offset);
            waveColor2.rgb *= (1 - (sin(_Time.x * _WaveSpeed + _WaveDelta + noiserColor.r) + 1) / 2) * noiserColor.r;

            //折射 & 反射
            offset = normal.xy * _Distortion * GrabPass_TexelSize.xy;
            IN.proj.xy = offset * IN.proj.z + IN.proj.xy;
            fixed3 refrCol = tex2D(GrabPass, IN.proj.xy / IN.proj.w).rgb;
            fixed3 reflaction = texCUBE(_Cubemap, WorldReflectionVector(IN, normal)).rgb;
            //菲涅耳反射公式
            fixed fresnel = _FresnelScale + (1 - _FresnelScale) * pow(1 - dot(IN.viewDir, WorldNormalVector(IN, normal)), 5);
            //根据菲涅耳系数插值
            fixed3 refrAndRefl = lerp(reflaction, refrCol, saturate(fresnel));

            o.Normal = normal;
            //控制反光、折射等
            o.Albedo = (c + (waveColor.rgb + waveColor2.rgb) * waveB) * refrAndRefl;
            o.Gloss = _Gloss;
            o.Specular = _Specular;
            //控制透明度
            o.Alpha = min(_TranAmount, deltaDepth) / _TranAmount;
        }
        ENDCG
    }
    FallBack "Diffuse"
}
  • 等纹理烘培好了,就可以直接放上去了,顺便把Reflection Probe的active设置为False:
    放入烘焙的纹理
  • 最终效果如下:

反射 & 折射效果调试

  • 一个比较快捷的方法就是把法线纹理先去掉:
    去掉法线纹理后

Draw Call过多问题查看

该问题原项目、代码并没有做优化

  • 运行项目后,我们打开Frame Debug,会发现之前烘焙的时候,明明都把物体的static勾上了,但DrawCall还是离奇的多:
    300+Draw Call
  • 展开来看,可以看到被合批的只有石头,地面和树都没有被合批:
    只有石头被合批了
  • 我们先来看树为什么没有合批:
    树没合批的原因
  • 解决树没有没有合批的问题也很简单,我们只需要把挂在书上的两个shader,都勾上Enable GPU Instancing即可,如下图所示:
    Enable GPU Instancing
    优化后
  • 至于地面,目前的核心原因是材质不同导致的,暂时没有很好的办法直接优化(也是我太菜了,接触不多):
    地面无法合批原因

  • 最后,有很多东西其实都想写,但碍于时间关系,还是等有空再修修补补吧