HiShader

HiShader

Mesh

1.MeshFilter(网格过滤器)

img

主要从众多的资源中挑选需要的Mesh,把它丢给MeshRender

MeshRender(网格渲染)

img

主要是负责把MeshFilter丢过来的Mesh,绘制显示到我们的场景中

3.Material(材质)

img

MaterialMeshRenderer中非常重要的角色,它的配置决定了物体表面的外观将以怎样的质地呈现到我们眼前。

Mesh 网格

img

Mesh指的就是模型的网格,它决定了物体的表面形状是怎样的,一个模型的表面大多是由多个彼此相连的三角面构成,当然也有其它类型。我们平时听到的建模,可以简单理解为就是在建网格,那为什么Unity中的网格大多都是三角形而不是四边形呢?正所谓一生二,二生三,三生万物。三角形可以说是最为基础的面了,可以简单理解为三角形具有更广泛的适用性,而Mesh则是构成这些三角面所需的信息集合。

通过 Mesh data - Unity 手册 我们可以看到构成这些三角面所需的信息。

Vertices 顶点数组 Vector3[]

顾名思义它存储的是顶点的相关信息,所谓点成线,线成面,可以理解为这里面存储的是构成网格面全部的点

Topology 拓扑类型

它存储的就是一个类型信息,可以理解为它是图形表面排列结构的组成方式,Unity给我们提供了5种拓扑类型,三角面、四边形、线条、虚线、点阵,最常用的则是三角面。

Indices 索引数组 int[]

它是每个三角面顶点 的索引,可以理解为他存储了构网格三角面所用到的顶点索引。

Vertex data 顶点数据

img

它包含了顶点的位置、法线、切线、UV等属性

Normal 法线 Vector3[]

法线就是垂直于该顶点三角面的一条三维向量,它只有方向,没有大小。法线的方向就是顶点三角面朝外的方向。假设我们面前有一面镜子,它的正中心会有一条法线垂直于镜面指向我们,指向我们的面就是正面,相反就是背面

Tangent 切线 Vector3[]

它是垂直于法线的一条向量,而由于垂直于法线的向量有无数条,所以切线最终是由UV坐标来决定朝向的

UV 纹理坐标 Vector2[]

上面所说的UV坐标其实就是它,U增长的方向就是切线的方向,它和三维空间的X, Y, Z较为类似,它是一个二维的坐标系统,模型网格除了有三维空间的xyz坐标外,还有一个二维的UV坐标,在UV坐标中,U和V分别代表顶点在Texture水平和垂直方向上的采样坐标,这些坐标通常位于(0,0)和(1,1)之间,(0,0)代表最左下角,而(1,1)代表最右上角。这就跟平时装修房子贴墙纸一样,可以理解为它是Texture映射到模型表面的依据,模型顶点 会依据UV坐标对Texture进行采样。

Index data 索引数据

这个数据取决于拓扑类型,如果是三角面他储存的就是[0,1,2],四边形储存的就是[0,1,2,3],这个索引数值对应的就是顶点数组的下标。

案例

定义立方体顶点数组

正常来说立方体共有6个面,每个面由2个三角面组成,三角面有3个顶点数据,所以正常来说每个面需要有6个顶点数据,一共需要36个三角面顶点索引。那为什么这里我们每个面只用到了4个顶点数据呢,这是因为每个面的2个三角面顶点数据中有两个顶点它们的数据是共同的,Unity会直接通过索引找到相对应的数据。

img

看到这里,可能又会有人觉得奇怪,既然共用的顶点数据可以通过索引找到,为什么一个立方面只有8个顶点,为什么这里不直接用8个顶点数据,而是需要24个?

这是因为Unity中不仅依靠这个三角面的索引数组索引三角面的顶点坐标,而且索引纹理坐标,索引法线向量。而立方体的每个顶点都参与了3个平面,而这个顶点相对于这3个平面来说,虽然顶点数据相同,但它们的法线向量是不同的,这个顶点在渲染这3个平面的时候则需要索引到不同的法线向量。而在Unity中由于顶点坐标和法线向量是由同一个索引值取得的,所以这里立方体一共8个顶点,每个顶点我们要存3份,刚好是24个顶点数据:

2.渲染管线

渲染管线

渲染管线通常来说就是在虚拟相机、三维物体、光源、照明模式、纹理等诸多条件都给定的情况下,生成 或是绘制一幅二维图像的过程。

一般这个过程会分为四个主要阶段:应用程序阶段、几何阶段、光栅化阶段、像素处理阶段。而每个阶段 又会分为很多个部分。

img

应用程序阶段 (The Application Stage) CPU

它最主要是负责数据的准备,也就是准备后面的阶段 所需的数据,像如模型,贴图,光照,相机位置等信息。

-————————————————————————————————————

几何阶段(The Geometry Stage) GPU

顶点着色:可编程部分,顾名思义,它会对逐个顶点相关的信息进行处理,生成图元,计算并传递给接下来的渲染流程。就像我们平时切菜一样,这个阶段就是为了控制食材的形态,

它对应的则是这里的VertxShader,它的工作主要是计算顶点的位置、法线、纹理坐标,根据材质、纹理、以及光源属性进行顶点光照的计算,平时常见的顶点动画一般就是在这里实现的。

[ 图元:可以简单理解为它是渲染管线中所有点,线,面的统称 ]

几何着色: 可选可编程部分,并非所有GPU都支持 ,它可以把简单的图元拓展成更复杂的形式,通常我们认为,这两大着色器共同构成了 几何阶段的可编程部分。

裁剪 : 固定功能硬件实现,对顶点几何两大着色器的输出结果进行处理,它会把完全处于视锥体交界外 以及屏幕窗口外的 几何体部分裁剪掉, 只留下用户能看到的部分,并且对生成的新顶点部分进行插值,输送给接下来的阶段。

-—————————————————————————-

光栅化阶段(Rasterization) GPU

屏幕映射:经过裁剪之后,硬件会通过透视除法将物体从 裁剪空间 变换 为 标准化设备坐标也叫NDC,之后GPU会把得到的NDC空间坐标下的顶点,映射到屏幕空间坐标中,进行图元装配,这一步会计算微分、边方程和其他三角形数据,三角形的朝向剔除就是在这个阶段完成的。

NDC:全称Normalized Device Coordinates,一般来说裁剪完成后,会通过透视除法,将物体从裁剪空间 变换为标准化设备坐标NDC,透视除法是将裁剪空间中,顶点的4个分量都除以w分量,从裁剪空间转换到NDC。它是一个长宽高取值范围为[-1,1]的立方体,之所以要转到NDC,是为了方面我们后面进行视口变换把它映射到屏幕空间,不过Unity已经帮我们都完成这些啦,所以具体的数学推算这里不做讲解,有需要的小伙伴这里推荐大家看一下闫令琪老师的 GAMES101: 现代计算机图形学入门 (ucsb.edu)

img

Vertex Shader -> Clip Space -> 透视除法 -> NDC -> 视口变换 -> Screen Space -> Fragment Shader

图元装配:

主要是计算微分(differentials)、边方程(edge equations)和其他三角形数据(顶点属性插值)

img

光栅化:

它会在每个像素点上生成一个片元,如果开启了多重采样抗锯齿,就会对每个像素进行多次采样,产生多个片元,最终进行混合来达到抗锯齿的效果。

[ 片元:是光栅化之后产生的像素点,因为没有被画到屏幕上,不能被直接称为像素一个像素的最终结果可能是由多个片元来决定的,渲染管线为了细分,就单独创造了片元这个词来描述它,片元只是渲染管线的概念 ]

[ 像素:则是最后写到图像上的值 ]

-————————————————————————————————————

像素处理阶段 ( Pixel Processing )GPU

像素 (片元) 着色器:可编程部分,它的工作主要是根据顶点的插值属性,进行逐像素计算,因为它需要处理每一个像素,所以这也是最耗时的一个阶段。它的输入输出都是片元数据,输入的数据是 颜色 和纹理坐标,输出的则是计算后所得的每个像素的色彩值,像是逐像素光照、反射、阴影等等更为复杂的效果都是可以在这里实现的,这一步更像是再给我们的食材上色。

合并:只可配置不可编程部分,在一系列的测试后会进行合并,所谓的测试则是判断一个像素点最终是否应该被显示在屏幕上,通过测试的颜色会和缓冲区的颜色叠加混合。

-————————————————————————————————————

坐标空间

模型空间 以物体本身为原点的坐标空间,世界空间以世界的(0,0)为原点的坐标空间,视图空间 以相机为原点的坐标空间,描述的物体在相机的哪个位置,裁剪空间 顶点坐标乘以MVP矩阵之后所在的空间,屏幕空间 窗口屏幕上的二维像素坐标空间。

img

-————————————————————————————————————

Shader

Shader比较学术的百科回答就是用来实现图像渲染的,用来替代固定渲染管线的可编辑程序。其中Vertex Shader(顶点着色器)主要负责顶点的几何关系等的运算,Pixel Shader(像素着色器)主要负责片元颜色等的计算。

Unity中的Shader类型

img

Standard Surface Shader

标准表面着色器,它是一种基于物理的着色系统,可以理解为 它是通过对物理现象的简单模拟,可以实现生活中各种物品的效果,比如石头、木材、玻璃、塑料和金属等等。

Unlit Shader

它是最简单的着色器,与 Standard Surface Shader 相比,它去除了冗长的光照公式以及阴影解算,因此得名 Unlit,翻译过来就是无光照,也正因如此,它只由最基础的 Vertex Shader 和 Fragment Shader 组成,最为基础易懂。

Image Effect Shader

它其实也是也是顶点片元着色器,不过它主要针对实现各种屏幕后处理效果,那后处理是什么呢?一般像是泛光、调色、景深、模糊等基于最终整个屏幕画面而进行再次处理的就是后处理,这里做个简单的了解即可。

Compute Shader

计算着色器,它是在GPU中运行的一段程序,独立于常规渲染管线之外的,它可以直接将GPU作为并行处理器加以利用,从而使GPU不仅具有3D渲染能力,还具有其他的运算能力。一般会在需要大量并行计算的时候使用。

Ray Tracing Shader

光线追踪着色器,光线追踪是指从摄像机出发的若干条光线,每条光线会和场景里的物体求交,根据交点位置获取表面的材质、纹理等信息,并结合光源信息计算光照。相对于传统的光栅化渲染,光线追踪可以轻松模拟各种光学效果,如反射、折射、散射、色散等。但由于在进行求交计算时需要知道整个场景的信息,它的计算成本也是非常高的。

-————————————————————————————————————

ShaderLab

Unity为我们封装的着色器语言,而目前主流的着色器语言有3种,基于OpenGL的GLSL / 基于DX的HLSL / NVIDIA公司的CG

GLSL与HLSL分别是基于OpenGL和Direct3D的接口,两者不能混用。而CG则是为了使图形硬件的编程变得和 C语言编程一样方便自由,它本身基于C语言。如果你之前使用过C系语言其中的任意一个,那CG的语法也是比较容易掌握的。但其实由于Microsoft和NVIDIA的相互协作,他们在标准硬件光照语言的语法和语义上达成了一致,所以HLSL和Cg其实可以看为是同一种语言。

ShaderLab则是Unity在HLSL和CG的基础之上封装的只属于Unity的着色器语言,它的灵活性更高,而且不再需要将 Shader 的配置 硬写在引擎代码中,本质是在底层着色语言的基础上,额外提供了声明信息,以数据驱动的方式使我们在渲染管线内自由发挥。

-—————————————————————————-

// Shader 的路径名称  默认为文件名,也可以与文件名不同
Shader "Unlit/HiShader"
{
    // 属性 
    // Material Inspector显示的所有参数都在需要在这里进行声明
    Properties
    {
        // 通常所有属性名都以下划线字符开头 _MainTex
        _MainTex ("Texture", 2D) = "white" {}
        
        // 比较常见的属性类型
        // ————————————————————————————————————————————————
        //_Integer ("整数(新版)", Integer) = 1
        _Int ("整数(旧版)", Int) = 1
        _Float ("浮点数", Float) = 0.5
        _FloatRange ("浮点数滑动条", Range(0.0, 1.0)) = 0.5
        // Unity包含以下内置纹理, 可以直接填充
        // “white”(RGBA:1,1,1,1)
        // “black”(RGBA:0,0,0,1)
        // “gray”(RGBA:0.5,0.5,0.5,1)
        // “bump”(RGBA:0.5,0.5,1,0.5)
        // “red”(RGBA:1,0,0,1)
        _Texture2D ("2D纹理贴图", 2D) = "red" {}
        // 字符串留空或输入无效值,则它默认为 “gray”
        _DefaultTexture2D ("2D纹理贴图", 2D) = "" {}
        // 默认值为 “gray”(RGBA:0.5,0.5,0.5,1)
        _Texture3D ("3D纹理贴图", 3D) = "" {}
        _Cubemap ("立方体贴图", Cube) = "" {}
        // Inspector会显示四个单独的浮点数字段
        _Vector ("Example vector", Vector) = (0.25, 0.5, 0.5, 1)
        // Inspector会显示拾色器拾取色彩RGBA值
        _Color("色彩", Color) = (0.25, 0.5, 0.5, 1)
        // ————————————————————————————————————————————————
        
        // 除此之外 属性声明还可以具有一个可选特性 用来告知Unity如何处理它们
        // HDR可以使色彩亮度的值超过1
        [HDR]_HDRColor("HDR色彩", Color) = (1,1,1,1)
        // Inspector隐藏此属性
        [HideInInspector]_Hide("看不见我~", Color) = (1,1,1,1)
        // Inspector隐藏此纹理属性的Scale Offset字段
        [NoScaleOffset]_HideScaleOffset("隐藏ScaleOffset", 2D) = "" {}
        // 指示纹理属性为法线贴图,如果分配了不兼容的纹理,编辑器则会显示警告。
        [Normal]_Normal("法线贴图", 2D) = "" {}
    }
    
    // 子着色器 
    // 一个Shader至少有一个或者多个子着色器SubShader,这些子着色器互不干扰,且只有一个会运行
    // 在加载shader时Unity会遍历所有SubShader列表,并最终选择用户机器支持的第一个
    SubShader
    {
        // 可以通过Tags来向子着色器分配标签
        // 只可以写在SubShader语块内,不可写在Pass内
        /* 以键值对的形式存在,可以出现多个键值对
        Tags { 
            "TagName1" = "Value1"
            "TagName2" = "Value2"
            "TagName3" = "Value3"
            ...
            }
        */
        
        // RenderPipeline: 声明子着色器是否与通用渲染管线 (URP) 或高清渲染管线 (HDRP) 兼容
        // 仅与 URP 兼容
        // Tags { "RenderPipeline"="UniversalRenderPipeline" }
        // 仅与 HDRP 兼容
        // Tags { "RenderPipeline"="HighDefinitionRenderPipeline" }
        // RenderPipeline不声明或任何其他值表示与 URP 和 HDRP 不兼容
        // ————————————————————————————————————————————————
        
        // Queue: 声明渲染队列
        // Tags { "Queue"="Background" } // 最早被调用的渲染,用来渲染天空盒或者背景
        // Tags { "Queue"="Geometry" }   // 这是默认值,用来渲染非透明物体(普通情况下,场景中的绝大多数物体应该是非透明的)
        // Tags { "Queue"="AlphaTest" }  // 用来渲染经过Alpha Test的像素,单独为AlphaTest设定一个Queue是出于对效率的考虑
        // Tags { "Queue"="Transparent" }// 以从后往前的顺序渲染透明物体
        // Tags { "Queue"="Overlay" }    // 用来渲染叠加的效果,是渲染的最后阶段(比如镜头光晕等特效)
        // ————————————————————————————————————————————————
        
        // RenderType: 用来区别这个Shader要渲染的对象是属于什么类别的。
        // 设置渲染类型 用一种称为着色器替换的技术在运行时交换子着色器,用来区别这个Shader要渲染的对象是属于什么类别的
        // 这里表示非透明物体渲染
        Tags { "RenderType"="Opaque" }
        // 更多详细内容可参考官网文档 https://docs.unity.cn/cn/2021.3/Manual/SL-SubShaderTags.html
        
        // LOD (Level of Detail)
        LOD 100

        // 每个子着色器由多个通道组成,许多简单的着色器只使用一个通道,但想要一些更复杂的效果,着色器可能需要更多通道
        // 一个Pass就是一次绘制,可以看成是一个Draw Call而Pass的意义在于多次渲染,
        // 如果你有一个Pass,那么着色器只会被调用一次,如果你有多个Pass的话,
        // 那么就相当于执行多次SubShader了,这就叫双通道或者多通道。
        
        // Draw Call:其实就是CPU调用图像编程接口的渲染命令,CPU每次调用DrawCall,都需要向GPU发送许多数据啊、渲染状态等等,
        // 一旦CPU执行完应用阶段,GPU就会开始执行这次的渲染流程。而GPU渲染的速度比CPU提交命令的速度要快的多,
        // 所以如果DrawCall数量过多的情况下,CPU需要进行大量的计算,进而就会导致CPU过载,影响游戏的运行效率。
        Pass
        {
            CGPROGRAM
            // 声明顶点着色器
            #pragma vertex vert
            // 声明像素着色器
            #pragma fragment frag
            // 使雾生效
            #pragma multi_compile_fog

            // 引用CG的核心代码库
            #include "UnityCG.cginc"

            // 应用程序阶段结构体
            struct appdata
            {
                // 参考:https://docs.microsoft.com/zh-cn/windows/win32/direct3dhlsl/dx-graphics-hlsl-semantics
                // POSITION 着色器语言的语义,用来限定着色器的输入输出值的类型
                // 模型空间的顶点坐标
                float4 vertex : POSITION;
                // 模型的第一套UV坐标
                float2 uv : TEXCOORD0;
            };

            struct v2f
            {
                // UV
                float2 uv : TEXCOORD0;
                UNITY_FOG_COORDS(1)
                // SV_POSITION 当这个值需要作为输出值输出给系统用的时候 前面需要加SV_前缀
                // 当然因为有向下兼容的机制 不加也没啥太大问题
                float4 vertex : SV_POSITION;
            };

            // 在Properties中声明的参数要在这里相对应的定义后才可以使用
            sampler2D _MainTex;
            float4 _MainTex_ST;

            // 定义顶点着色器函数 函数名要与声明顶点着色器名称相同
            v2f vert (appdata v)
            {
                v2f o;
                // 将顶点坐标从模型空间变换到裁剪空间
                o.vertex = UnityObjectToClipPos(v.vertex);
                // Transforms 2D UV by scale/bias property
                // #define TRANSFORM_TEX(tex,name) (tex.xy * name##_ST.xy + name##_ST.zw)
                // 等价于v.uv.xy * _MainTex_ST.xy + _MainTex_ST.zw;
                // 简单来说,TRANSFORM_TEX主要作用是拿顶点的uv去和材质球的tiling和offset作运算,
                // 确保材质球里的缩放和偏移设置是正确的
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                UNITY_TRANSFER_FOG(o,o.vertex);
                return o;
            }

            // SV_Target可以视为COLOR ,虽说他也是作为输出值输出给系统的
            // 但它其实是告诉系统把输出的颜色值存储到RenderTarget中
            // 所以这里我们用SV_Target
            fixed4 frag (v2f i) : SV_Target
            {
                // 采样2D纹理贴图
                fixed4 col = tex2D(_MainTex, i.uv);
                // 应用雾
                UNITY_APPLY_FOG(i.fogCoord, col);
                // 返回经过处理后的最终色彩
                return col;
            }
            ENDCG
        }
    }
}

案例

VertexShader

找到 VertexShader,这里将通过调整顶点的 Y 轴位置实现一个简单的压扁效果

v2f vert (appdata v)
{
         v2f o;
         o.uv = TRANSFORM_TEX(v.uv, _MainTex);
         // 模型空间转到世界空间
         float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
         // 压Y轴位置 这里把世界空间下顶点的y减去最低部y的值乘上一个系数
         // 然后再用y去减去这个值,就可以通过这个系数来控制兔子被压扁的程度
         float y = worldPos.y - (worldPos.y - _Bottom) * _Value;
         // 最终世界空间位置
         float3 tempWorld = float3(worldPos.x,y,worldPos.z);
         // 世界空间转裁剪空间
         o.vertex = UnityWorldToClipPos(tempWorld);
         return o;
}
Properties
{
    _Value ("压扁系数",Range(0, 1)) = 0
    _Bottom ("底部", float) = 0
}
                                        
    float _Value;
    float _Bottom;

最后拖拽压扁系数条就可以看到效果啦~

img

-—————————————————————————-

Pixel Shader

这里创建一个 Cube,相比之下,虽然它们都是三维模型,但这只兔子看起来就跟纸片一样,完全没有立体感。

img

这是因为 Unlit 默认是不受光材质,纹理什么颜色它就直接显示出来了,但通常来说,光照是三维世界不可或缺的部分,现实世界中,当光照射到物体表面时, 物体对光会发生反射、透射、吸收、折射等被物体反射的光进入视觉系统,使看见物体的表面有明暗之分,为了模拟这一现象,科研家建立了一些数学模型来替代复杂的物理模型,统称为光照模型。

img

比较常见的光照模型有漫反射的 Half Lambert 模型 ,以及镜面反射的 Blinn-Phong 模型。 Half Lambert 能够较好地表现粗糙表面上的光照现象,像如石灰墙,纸张等等,但是在渲染金属材质制成的物体时,则会显得呆板,表现不出光泽。主要原因是其没有考虑到镜面反射效果,所以 Blinn-Phong 对其进行了很好的补充。

img

这里我们将通过Pixel Shader实现最简单的 Half Lambert ,首先拿到计算光照需要的模型法线和世界坐标,在v2f的结构体里将它们进行定义,

struct v2f
{
     float2 uv : TEXCOORD0;
                                                
     // 计算光照需要用到法线和世界位置
     // 通常使用TEXCOORDn语义来修饰float2, float3, float4类型
     float3 worldNormal: TEXCOORD1;
     float3 worldPos:TEXCOORD2;
                                                
     UNITY_FOG_COORDS(1)
     float4 vertex : SV_POSITION;
};

在顶点着色器中将世界坐标和法线进行处理,传递给接下来的像素着色器

v2f vert (appdata v)
{
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);
    // 模型空间转到世界空间
    o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
    // 法线向量归一化
    o.worldNormal = normalize(UnityObjectToWorldNormal(v.normal));
    return o;
}

在像素着色器中,首先通过世界坐标拿到光照的方向,也就是所谓的入射光,用法线点乘入射光,就可以得到入射光与模型表面的夹角,由于当入射光和法线夹角的余弦值为负数的时候,所得到的结果始终都是零,就会导致照不到的地方一片漆黑,所以这里需要乘0.5再加0.5 ,这样就可以把原本-1到1的取值范围变为0-1的取值范围。最后把求出来的光照亮度叠加到最终的像素色彩值就可以啦

fixed4 frag (v2f i) : SV_Target
{
     // sample the texture
     fixed4 col = tex2D(_MainTex, i.uv);
     // 得到光照方向
     float3 worldLightDir = UnityWorldSpaceLightDir(i.worldPos);
     // NoL代表表面接受的能量大小
     float NoL = dot(i.worldNormal, worldLightDir);
     // 计算half-lambert亮度值
     float halfLambert = NoL * 0.5 + 0.5;
     // apply fog
     UNITY_APPLY_FOG(i.fogCoord, col);
     return col * halfLambert;
}

这样最终的效果就会更符合我们的视觉观感。

img

3.卡通渲染

NPR 是 Non-Photorealistic Rendering 的简称,也就是图形渲染中的非真实感渲染,常见的 NPR 渲染包括卡通渲染、油画渲染、像素感渲染、素描画、水墨画等类型,

卡通渲染 是非真实感渲染中应用最广的渲染技术,在游戏和影视领域都是非常常见的。它主要是通过简化并剔除画面原本所包含的混杂部分,给人以独特的感染力和童趣,通常来说卡通渲染有4个要素 轮廓描边、色阶、高光、边缘光

实战案例

轮廓描边

img

渲染轮廓线的方式有很多种, 在这里带大家熟悉其中最简单的一种, 对物体做两次渲染, 第二次渲染时开启正面剔除,将顶点沿法线向外延深一段距离,(放大物体),实现轮廓线,这里就用到我们之前提到的多Pass渲染。

打开ToonShader,首先在Properties语块中声明轮廓线相关的两个属性,方便我们进行之后的调整:

Properties
{
	_MainTex("Texture",2D) = "white" {}

	_OutlineWidth ("Outline Width", Range(0.01, 1)) = 0.01
	_OutLineColor ("OutLine Color", Color) = (0.5,0.5,0.5,1)
}

原本的pass我们暂时先不用动,直接新增一个Pass来做轮廓线的渲染,这里记得定义一下我们刚刚声明的宽度和颜色

Shader "Unlit/ToonShader"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _OutlineWidth ("Outline Width", Range(0.01, 1)) = 0.01
   _OutLineColor ("OutLine Color", Color) = (0.5,0.5,0.5,1)
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            // make fog work
            #pragma multi_compile_fog

            #include "UnityCG.cginc"

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

            struct v2f
            {
                float2 uv : TEXCOORD0;
                UNITY_FOG_COORDS(1)
                float4 vertex : SV_POSITION;
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                UNITY_TRANSFER_FOG(o,o.vertex);
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                // sample the texture
                fixed4 col = tex2D(_MainTex, i.uv);
                // apply fog
                UNITY_APPLY_FOG(i.fogCoord, col);
                return col;
            }
            ENDCG
        }
        
        Pass
        {  
       // 开启前向剔除 表示剔除前面 只显示背面
            Cull Front
            
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            
            // 线条宽度
            float _OutlineWidth;
            // 线条颜色
            float4 _OutLineColor;

            struct appdata 
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
                // 法线
                float3 normal : NORMAL;
            };

            struct v2f
            {
      float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };

            v2f vert (appdata v) 
            {
                v2f o;
      // 顶点沿着法线方向外扩(放大模型)
      float4 newVertex = float4(v.vertex.xyz + v.normal * _OutlineWidth * 0.01 ,1);
      // UnityObjectToClipPos(v.vertex) 将模型空间下的顶点转换到齐次裁剪空间
                o.vertex = UnityObjectToClipPos(newVertex);
                return o;
            }

            half4 frag(v2f i) : SV_TARGET 
            {
           // 返回线条色彩
                return _OutLineColor;
            }
            
            ENDCG
        }
    }
}

至此我们的第一步轮廓描边就做好了

-————————————————————————————————————

色阶

img

通常来说都是由它来决定画面色彩的丰富度饱满度精细度,而大部分卡通渲染习惯降低色阶,用简单的明暗关系来描述世界,使画面扁平又不失层次感,这里还是用上节讲的half Lambert光照模型,不过这看起来一点都不卡通,我们需要让它明暗分明一点

// 得到顶点法线
float3 normal = normalize(i.worldNormal);
// 得到光照方向
float3 worldLightDir = UnityWorldSpaceLightDir(i.worldPos);
// NoL代表表面接受的能量大小
float NoL = dot(i.worldNormal, worldLightDir);
// 计算half-lambert亮度值
float halfLambert = NoL * 0.5 + 0.5;

// 通过亮度值计算线性ramp
float ramp = linearstep(_RampStart, _RampStart + _RampSize, halfLambert);
float step = ramp * _RampStep;  // 使每个色阶大小为1, 方便计算
float gridStep = floor(step);   // 得到当前所处的色阶
float smoothStep = smoothstep(gridStep, gridStep + _RampSmooth, step) + gridStep;
ramp = smoothStep / _RampStep;  // 回到原来的空间
// 得到最终的ramp色彩
float3 rampColor = lerp(_DarkColor, _LightColor, ramp);
rampColor *= col;

高光

img

那上节讲过half lambert 它是漫反射,而漫反射是没有考虑高光的,所以这里我们需要用blinnphone来做镜面反射。

用相机的位置减去世界位置得到视向量,也就是当前物体表面指向摄像机的方向,由于反射不太好算,所以这里通过 视向量 和 光照方向 得到角平分线,也就是半程向量。通过 法线方向 点乘 半程向量 就可以得到 法线 和 半程向量 的 夹角,由此就 可以推断出 视向量 和 反射向量 的 接近程度,用 noh 来 计算高光 的 亮度值,而这个参数 SpecPow 则是 控制高光的 光泽度,也就是 高光 亮斑的 范围,和色阶同样,用smoothStep来做个柔边的效果再把高光颜色和强度值加上,最后我们把漫反射和高光混合,就可以来调试效果啦。

// 得到视向量
float3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
// 计算half向量, 使用Blinn-phone计算高光
float3 halfDir = normalize(viewDir + worldLightDir);
// 计算NoH用于计算高光
float NoH = dot(normal, halfDir);
// 计算高光亮度值
float blinnPhone = pow(max(0, NoH), _SpecPow * 128.0);
// 计算高光色彩
float3 specularColor = smoothstep(0.7 - _SpecSmooth / 2, 0.7 + _SpecSmooth / 2, blinnPhone) 
                                             * _SpecularColor * _SpecIntensity;

边缘光

img

首先我们需要得知哪里是我们看到的边缘,当我们的视向量和法线向量的夹角越接近直角时它就越靠近边缘,先拿到视向量和法向量的夹角,就可以看到,越是接近边缘的地方越暗,但边缘光一般都是越接近边缘越亮,所以给 1- 反转一下,但正常来说阴影部分是不应该有边缘光的,所以要把漫反射加一下,那到至此边缘光就正确啦~

// 计算NoV用于计算边缘光
float NoV = dot(i.worldNormal, viewDir);
// 计算边缘光亮度值
float rim = (1 - max(0, NoV)) * NoL;
// 计算边缘光颜色
float3 rimColor = smoothstep(_RimThreshold - _RimSmooth / 2, _RimThreshold + _RimSmooth / 2, rim) * _RimColor;

最后把各项颜色混合就大功告成啦~

// 混合颜色
float3 finalColor = saturate(rampColor + specularColor + rimColor);
return float4(finalColor,1);

img

完整代码

Shader "Custom/ToonShader"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        
        _OutlineWidth ("Outline Width", Range(0.01, 1)) = 0.01
      _OutLineColor ("OutLine Color", Color) = (0.5,0.5,0.5,1)
        
        _RampStart ("交界起始 RampStart", Range(0.1, 1)) = 0.3
        _RampSize ("交界大小 RampSize", Range(0, 1)) = 0.1
        [IntRange] _RampStep("交界段数 RampStep", Range(1,10)) = 1
        _RampSmooth ("交界柔和度 RampSmooth", Range(0.01, 1)) = 0.1
        _DarkColor ("暗面 DarkColor", Color) = (0.4, 0.4, 0.4, 1)
        _LightColor ("亮面 LightColor", Color) = (0.8, 0.8, 0.8, 1)
        
        _SpecPow("SpecPow 光泽度", Range(0, 1)) = 0.1
        _SpecularColor ("SpecularColor 高光", Color) = (1.0, 1.0, 1.0, 1)
        _SpecIntensity("SpecIntensity 高光强度", Range(0, 1)) = 0
        _SpecSmooth("SpecSmooth 高光柔和度", Range(0, 0.5)) = 0.1
        
        _RimColor ("RimColor 边缘光", Color) = (1.0, 1.0, 1.0, 1)
        _RimThreshold("RimThreshold 边缘光阈值", Range(0, 1)) = 0.45
        _RimSmooth("RimSmooth 边缘光柔和度", Range(0, 0.5)) = 0.1
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
                float3 normal: NORMAL;  // 计算光照需要用到模型法线
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
                // 计算光照需要用到法线和世界位置
                float3 worldNormal: TEXCOORD1;
                float3 worldPos:TEXCOORD2;
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;
            float _RampStart;
            float _RampSize;
            float _RampStep;
            float _RampSmooth;
            float3 _DarkColor;
            float3 _LightColor;

            float _SpecPow;
            float3 _SpecularColor;
            float _SpecIntensity;
            float _SpecSmooth;

            float3 _RimColor;
            float _RimThreshold;
            float _RimSmooth;

            float linearstep (float min, float max, float t)
            {
                return saturate((t - min) / (max - min));
            }

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                // 向下传输这些数据
                o.worldNormal = normalize(UnityObjectToWorldNormal(v.normal));
                o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                // sample the texture
                fixed4 col = tex2D(_MainTex, i.uv);
                
                //------------------------ 漫反射 ------------------------
                // 得到顶点法线
                float3 normal = normalize(i.worldNormal);
                // 得到光照方向
                float3 worldLightDir = UnityWorldSpaceLightDir(i.worldPos);
                // NoL代表表面接受的能量大小
                float NoL = dot(i.worldNormal, worldLightDir);
                // 计算half-lambert亮度值
                float halfLambert = NoL * 0.5 + 0.5;

                //------------------------ 高光 ------------------------
                // 得到视向量
                float3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
                // 计算half向量, 使用Blinn-phone计算高光
                float3 halfDir = normalize(viewDir + worldLightDir);
                // 计算NoH用于计算高光
                float NoH = dot(normal, halfDir);
                // 计算高光亮度值
                float blinnPhone = pow(max(0, NoH), _SpecPow * 128.0);
                // 计算高光色彩
                float3 specularColor = smoothstep(0.7 - _SpecSmooth / 2, 0.7 + _SpecSmooth / 2, blinnPhone)
                                        * _SpecularColor * _SpecIntensity;

                //------------------------ 边缘光 ------------------------
                // 计算NoV用于计算边缘光
                float NoV = dot(i.worldNormal, viewDir);
                // 计算边缘光亮度值
                float rim = (1 - max(0, NoV)) * NoL;
                // 计算边缘光颜色
                float3 rimColor = smoothstep(_RimThreshold - _RimSmooth / 2, _RimThreshold + _RimSmooth / 2, rim) * _RimColor;

                //------------------------ 色阶 ------------------------
                // 通过亮度值计算线性ramp
                float ramp = linearstep(_RampStart, _RampStart + _RampSize, halfLambert);
                float step = ramp * _RampStep;  // 使每个色阶大小为1, 方便计算
                float gridStep = floor(step);   // 得到当前所处的色阶
                float smoothStep = smoothstep(gridStep, gridStep + _RampSmooth, step) + gridStep;
                ramp = smoothStep / _RampStep;  // 回到原来的空间
                // 得到最终的ramp色彩
                float3 rampColor = lerp(_DarkColor, _LightColor, ramp);
                rampColor *= col;
                
                // 混合颜色
                float3 finalColor = saturate(rampColor + specularColor + rimColor);
                return float4(finalColor,1);
            }
            ENDCG
        }
        
        Pass
        {
            Cull Front
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
                // 法线
                float3 normal : NORMAL;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;

            // 线条宽度
            float _OutlineWidth;
            // 线条颜色
            float4 _OutLineColor;

            v2f vert (appdata v)
            {
                v2f o;
                float4 newVertex = float4(v.vertex.xyz +  normalize(v.normal) * _OutlineWidth * 0.05,1);
                o.vertex = UnityObjectToClipPos(newVertex);
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                return _OutLineColor;
            }
            ENDCG
        }
    }
    fallback"Diffuse"
}

Noise

SkyBox

Unity中天空的概念是,相机在渲染场景之前绘制的一种背景类型,它可以给场景提供深度感,使场景看上去比实际大小要大得多。

而天空本身可以包含任何无法触及的像如太阳月亮星星这些对象,来营造遥远的空间视觉感。本质上是把场景放置在天空的立方体内,在渲染场景前,Unity会首先渲染这个立方体由于是从里向外看,所以我们的天空永远在做背面渲染,那在Unity中通常将它称为SkyBox 天空盒。

其实在日常的开发中天空是被大多数人所忽略的一个部分,毕竟Unity默认已经准备好了一个还算可以的天空~ 但如果你不想要这千篇一律的效果,还可以通过Shader自定义更为别致的天空。

img

这里我们新建一个Shader和Material,点击Windows-> Rendering -> Lighting -> Environment

img

就可以看到配置SkyBoxMaterial的这一栏,替换成刚刚新建的材质就可以啦

img

-————————————————————————————————————

RayMarch

它属于体积渲染技术,像如云呀雾呀等等这些,它们都是很难用几何体来表现的,所以一般会把效果抽象成大量的体积像素来进行渲染,当然极光也不例外,地球上的极光是由来自 磁层 和 太阳风 的 带电高能粒子 被 地磁场导引 带进地球的大气层,并与高层大气热层 中的 原子碰撞造成的发光现象,所以它也属体积物体的一类。

img

所谓体积渲染,就是用3D离散采样数据集, 进行2D投影的过程,换句话说就是取体积物体的 一组2D切片纹理,进行多次叠加采样计算,得到最终的屏幕色彩值,可以想象成叠千层饼的感觉吧~

RayMarch中文的意思就是光线步进,可以简单理解为光线在一步一步的向前行进,核心理念呢就是以相机为原点,向屏幕上的每一个像素,发射一条射线,射线按照一定的步长向前步进,这个所谓的步长呢,就是每步的Size,检测当前光线与物体表面之间的距离 然后根据这个距离 调整光线的步长,直到抵达物体的表面,每向前步进一次 都会累积计算一些 我们所需的信息,当步进到某个状态的时侯,结束步进,根据最终返回的累积数据,对屏幕上的像素进行绘制

img

-————————————————————————————————————

Noise

它其实就是图像中一种亮度或颜色信息的随机变化,也可以说是 计算机图形学 中一类 随机算法。通常我们会利用噪声算法,混合色彩贴图、高度贴图、法线贴图、UV贴图等,去构造模拟自然界中各式各样的物体表面的纹理材质细节,关于详细的噪声生成算法这里不做过多赘述,直接用生成好的噪声贴图进行采样就好啦~

img

-————————————————————————————————————

实战案例 - 极光

模拟大气层

img

从0点开始向前发射一条射线,拿到射线的方向。极光一般都会产生在地球上空90-130千米处,所以接下来的本质其实是在模拟一个大气层

// 计算ray march
// 每个像素发射射线
float3 rayOriginal = 0;
float3 totalDir = i.worldPos - rayOriginal;
float3 rayDir = normalize(totalDir);

img

用倒数函数来求一下天空的曲率值,当y越接近于零的时侯,它的x越大,这个层也就越接近一个平面

// 拓展球面来计算march的起始点
// rcp -> reciprocal 求倒数
// 天空曲率
float skyCurvatureFactor = rcp(rayDir.y + _SkyCurvature);

img

沿射线的方向向外延伸,把刚刚求出来的天空曲率乘上,那为了使极光的重复度更可控,我们在最后乘上一个平铺系数,那至此大气层的最底面就设定好啦

// 无数条射线像外发射 就会形成一个球面 *天空曲率 就可以把它拍成一个球
float3 basicRayPlane = rayDir * skyCurvatureFactor * _AurorasTiling ;
// 从哪开始步进(大气层最底面)
float3 rayMarchBegin = rayOriginal + basicRayPlane;

光线步进

img

把模拟好的大气层底面作为步进的起始位置,同样在这里通过倒数函数算一下步长,步数越多,步的Size就越小,既然需要对每一步的信息进行累积计算,加一个for循环,为了使初始的几次采样 贡献更大, 所以可以用二次函数来着重一下初始采样,根据这个总距离求出当前的步进距离,根据射线方向和距离乘上天空曲率算出步进后的采样坐标。增加一个黑白贴图来决定极光的形状。

// 一步的大小
float stepSize = rcp(_RayMarchStep);
for (float i = 0; i < _RayMarchStep; i += 1)
{
     float curStep = stepSize * i;
     // 初始的几次采样贡献更大, 我们用二次函数着重初始采样
     curStep = curStep * curStep;
     // 当前步进距离
     float curDistance = curStep * _RayMarchDistance;
     // 步进后的位置
     float3 curPos = rayMarchBegin + rayDir * curDistance * skyCurvatureFactor;
     float2 uv = float2(-curPos.x,curPos.z);
     float curAuroras = tex2D(_MainTex, TRANSFORM_TEX(uv, _MainTex)).r;
}

色彩渐变拖尾

img

首先在for循环内,拿设定的色彩做初相,由于sin的范围是-1到1,所以要先把色彩范围转换到-1到1之间,通过 i 计算出当前步进层的色彩,最后 * 0.5再加0.5就返回到了原本0-1的范围区间。取两步色彩的平均值,使当前步进层的颜色更接近于本色,最后把当前步进层的色彩累积上去使色彩混合,就可以看到最基础的极光拖尾渐变色啦

// 极光色彩累积计算
// 由于sin的范围是-1到1,所以要先把颜色范围转换到-1到1之间,这通过i计算出当前步进层的色彩
 // 最后 * 0.5再加0.5就返回到了原本的0-1的范围区间
 float3 curColor = sin((_AurorasColor * 2 - 1) + i * 0.043) * 0.5 + 0.5;
// 取两步色彩的平均值 使颜色更接近于本色 
avgColor = (avgColor + curColor) / 2;
                    
// 混合颜色
color += avgColor * curAuroras * stepSize;

最后为了使色彩更为自然,增加一个强度衰减就大功告成啦

// for 内
// 强度衰减
curAuroras = curAuroras * saturate(1 - pow(curDistance, 1 - _AurorasAttenuation));
 // for 外
// 强度
color *= _AurorasIntensity;

动态极光

为了使极光动起来,这里需要用神奇的噪声贴图来做个UV扰动,所谓的UV扰动,它的概念简单来说就是,给UV采样通过噪声做随机的偏移,首先把噪声贴图采出来,给它乘上一个时间维度和速度,就可以使噪声随着时间流逝动起来啦,既然是给原本的UV采样做随机偏移,所以这里把噪声偏移加上之后乘以一个系数就可以了~

// for 内
// 计算扰动uv
float2 warp_vec = tex2D(_AurorasNoiseTex,TRANSFORM_TEX((uv * 2 + _Time.y * _AurorasSpeed),_AurorasNoiseTex));
 // 采样当前的噪声强度
float curNoise = tex2D(_MainTex, TRANSFORM_TEX((uv + warp_vec * 0.1), _MainTex)).r;
// 混合颜色
color += avgColor * curNoise * stepSize;

实战案例 - 星空

同样这里还是用神奇的噪声贴图来做璀璨的漫天繁星,其实直接采噪声贴图就可以有较为粗糙的星星了,

img

但这并不是我们想要的效果,太多太密集了,而且它不会随机一闪一闪亮晶晶,那这里可以做两代星星的循环迭代,既然要做星星的闪动效果,所以需要给它增加一个时间维度乘上星星的闪烁速度,通过Time来计算叠加区间的两层星星UV,由于取整了之后UV加不加的没什么意义,所以这里给它乘个0.3的魔法值,然后通过偏移好的UV去采样两层星星值就好啦

const float starTime = _Time.y * _StarShinningSpeed;

// 计算叠加区间的两层星星UV
const float2 beginMove = floor(starTime) * 0.3;
const float2 endMove = ceil(starTime) * 0.3;
const float2 beginUV = i.uv + beginMove;
const float2 endUV = i.uv + endMove;

// 采样两层星星的值
float beginNoise = tex2D(_StarNoiseTex, TRANSFORM_TEX(beginUV,_StarNoiseTex)).r;
float endNoise = tex2D(_StarNoiseTex, TRANSFORM_TEX(endUV,_StarNoiseTex)).r;

那为了使星星的密度变得更为可控,我们给它增加一个StarCount来调整,这一步做的就是 假设当这个StarCount是0.1的时候只显示 最亮的百分之十的星星,剩下比较暗的不做显示。最后根据这个Time去把两层星星的值混合一下就好啦~

// 减少星星
                                         beginNoise = saturate(beginNoise - (1 - _StarCount)) / _StarCount;
                                         endNoise = saturate(endNoise - (1 - _StarCount)) / _StarCount;

                                         const float fracStarTime = frac(starTime);
                                         // 混合两层星星值
                                         starColor = saturate(beginNoise - fracStarTime) + saturate(endNoise - (1 - fracStarTime));

最后为了使天空交界更为自然,加个天际线混合就大功告成啦~

// 混合天际线
color *= saturate(rayDir.y / _SkyLineSize + _SkyLineBasePow);

完整代码

Shader "Custom/Auroras"
{
    Properties
    {
        _MainTex ("AurorasTexture", 2D) = "white" {}
        _AurorasNoiseTex ("AurorasNoise", 2D) = "white" {}
        _StarNoiseTex ("StarNoise", 2D) = "white" {}
        _SkyColor ("天空颜色 SkyColor", Color) = (0.4, 0.4, 0.4, 1)
        _AurorasColor ("极光颜色 AurorasColor", Color) = (0.4, 0.4, 0.4, 1)
        _AurorasTiling("极光平铺 AurorasTiling", Range(0.1, 10)) = 0.4
        _AurorasSpeed ("极光变化速度 AurorasSpeed", Range(0.01, 1)) = 0.1
        
        _AurorasIntensity("极光强度 AurorasIntensity", Range(0.1, 20)) = 3
        _AurorasAttenuation("极光衰减 AurorasAttenuation", Range(0, 0.99)) = 0.4
        
        _SkyCurvature ("天空曲率 SkyCurvature", Range(0, 10)) = 0.4
        _RayMarchDistance("步进距离 RayMarchDistance", Range(0.01, 1)) = 2.5
        [IntRange] _RayMarchStep("步进步数 RayMarchStep", Range(1,128)) = 64
        
        
        _SkyLineSize("天际线大小 SkyLineSize", Range(0, 1)) = 0.06
        _SkyLineBasePow("天际线基础强度 SkyLineBasePow", Range(0, 1)) = 0.1
        
        _StarShinningSpeed ("星星闪烁速度 StarShinningSpeed", Range(0, 1)) = 0.1
        _StarCount("星星数量 StarCount", Range(0,1)) = 0.3
    }
    SubShader
    {
        Tags
        {
            "RenderType"="Opaque"
        }
        LOD 100

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            // make fog work
            #pragma multi_compile_fog

            #include "UnityCG.cginc"

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

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float3 worldPos : TEXCOORD1;
                float4 vertex : SV_POSITION;
            };

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.worldPos = mul(v.vertex, unity_ObjectToWorld);
                o.uv = v.uv;
                return o;
            }

            float3 _AurorasColor;
            float3 _SkyColor;
            float _AurorasIntensity;
            float _AurorasTiling;
            float _AurorasSpeed;
            float _AurorasAttenuation;
            float _SkyCurvature;
            float _RayMarchDistance;
            float _RayMarchStep;
            float _SkyLineSize;
            float _SkyLineBasePow;
            
            float _StarShinningSpeed;
            float _StarCount;

            sampler2D _MainTex;
            sampler2D _AurorasNoiseTex;
            sampler2D _StarNoiseTex;
            float4 _StarNoiseTex_ST;
            float4 _AurorasNoiseTex_ST;
            
            float4 _MainTex_ST;
            

            fixed4 frag (v2f i) : SV_Target
            {
                tex2D(_StarNoiseTex, TRANSFORM_TEX(i.uv,_StarNoiseTex)).r;
                
                // 星星
                float starColor = 0;
                
                const float starTime = _Time.y * _StarShinningSpeed;

                // 计算叠加区间的两层星星UV
                const float2 beginMove = floor(starTime) * 0.3;
                const float2 endMove = ceil(starTime) * 0.3;
                const float2 beginUV = i.uv + beginMove;
                const float2 endUV = i.uv + endMove;
                
                // 采样两层星星的值
                float beginNoise = tex2D(_StarNoiseTex, TRANSFORM_TEX(beginUV,_StarNoiseTex)).r;
                float endNoise = tex2D(_StarNoiseTex, TRANSFORM_TEX(endUV,_StarNoiseTex)).r;

                // 减少星星
                beginNoise = saturate(beginNoise - (1 - _StarCount)) / _StarCount;
                endNoise = saturate(endNoise - (1 - _StarCount)) / _StarCount;

                const float fracStarTime = frac(starTime);
                // 混合两层星星值
                starColor = saturate(beginNoise - fracStarTime) + saturate(endNoise - (1 - fracStarTime));
                
                
                // 计算ray march信息
                // 每个像素发射射线
                float3 rayOriginal = 0;
                float3 totalDir = i.worldPos - rayOriginal;
                float3 rayDir = normalize(totalDir);
                //clip(rayDir.y);

                // 拓展球面来计算march的起始点
                // reciprocal 求倒数
                // 天空曲率
                float skyCurvatureFactor = rcp(rayDir.y + _SkyCurvature);
                // 本质为模拟地球大气
                // 无数条射线像外发射 就会形成一个球面 *天空曲率 就可以把它拍成一个球
                float3 basicRayPlane = rayDir * skyCurvatureFactor * _AurorasTiling ;
                // 从哪开始步进
                float3 rayMarchBegin = rayOriginal + basicRayPlane;

                // ray march
                float3 color = 0;
                float3 avgColor = 0;
                // 一步的大小
                float stepSize = rcp(_RayMarchStep);
                
                for (float i = 0; i < _RayMarchStep; i += 1)
                {
                    float curStep = stepSize * i;
                    // 初始的几次采样贡献更大, 我们用二次函数着重初始采样
                    curStep = curStep * curStep;
                    // 当前步进距离
                    float curDistance = curStep * _RayMarchDistance;
                    // 步进后的位置
                    float3 curPos = rayMarchBegin + rayDir * curDistance * skyCurvatureFactor;
                    float2 uv = float2(-curPos.x,curPos.z);

                    // =====  极光动起来
                    // 计算扰动uv
          float2 warp_vec = 
                        tex2D(_AurorasNoiseTex,TRANSFORM_TEX((uv * 2 + _Time.y * _AurorasSpeed),_AurorasNoiseTex));
                    // 采样当前的噪声强度
                    float curNoise = tex2D(_MainTex, TRANSFORM_TEX((uv + warp_vec * 0.1), _MainTex)).r;
                    //curNoise = tex2D(_MainTex, TRANSFORM_TEX(uv, _MainTex)).r;
                    // =======================
                    
                    // 最后加强度衰减
                    curNoise = curNoise * saturate(1 - pow(curDistance, 1 - _AurorasAttenuation));
                    
                    // 极光色彩累积计算
                    // 由于sin的范围是-1到1,所以要先把颜色范围转换到-1到1之间,这通过i计算出当前步进层的色彩
                    // 最后 * 0.5再加0.5就返回到了原本的0-1的范围区间
                    float3 curColor = sin((_AurorasColor * 2 - 1) + i * 0.043) * 0.5 + 0.5;
                    
                    // 取两步色彩的平均值 使颜色更接近于本色 
                    avgColor = (avgColor + curColor) / 2;
                    
                    // 混合颜色
                    color += avgColor * curNoise * stepSize;
                }
                
                // 强度
                color *= _AurorasIntensity;

                // 混合天际线
                color *= saturate(rayDir.y / _SkyLineSize + _SkyLineBasePow);

                // 天空色
                color += _SkyColor;

                // 星星
                color = color + starColor * 0.9;
                
                return fixed4(color, 1);
            }
            ENDCG
        }
    }
}

PBR

基础概念

PBRPhysically Based Rendering的简称,也就是图形渲染中的真实感渲染,简单理解就是基于物理的渲染技术,但它只是对现实世界物理的一种近似,并非完全的物理渲染,与之前学习的NPR属于完全不同的两种渲染理念,PBR是以基于微平面理论能量守恒,以及物理的BRDF为核心的渲染理念。

微平面理论

img

简单来说就是物体表面持续放大至微观尺度后,任何平面都是由 无数微小的无序随机朝向的镜面组成。

平面越是粗糙,这个平面上的微平面的排列就越混乱,平面越是光滑,光线大体上会更趋向于同一个方向反射,形成更小更锐利的反射。

其实在微观尺度下,没有任何平面是完全光滑的。但由于这些微平面已经微小到无法逐像素继续进行区分,因此会假设一个粗糙度参数,然后用统计学的方法来估算微平面的粗糙程度,通过这个粗糙度计算出某个向量的方向,与微平面平均取向的一致性。

一般粗糙度越高的显示出来的镜面反射的轮廓要更大一些,相反则会更小更锐利。在实际的PBR 中,这种物体表面的不规则性一般会用粗糙度贴图或者高光度贴图来表示~

-————————————————————————————————————

能量守恒

img

说白了就是反射光的能量永远不能超过入射光的能量,为了遵守能量守恒定律,通常我们会对漫反射光和镜面反射光之间做出明确的区分。

在PBR中,镜面光指的就是光线射入物体表面时所反射的那部分能量,而漫反射光则是指光线进入物体后再均匀的发散出来的那部分能量。当光线碰撞到一个表面的时候,一部分能量被反射,另一部分能量进入物体内部。

img

不过不同的材质表面它的反射细节也会有所不同,通常会分为金属和非金属材质,其中金属表面由于有自由电子,所以进入表面的能量会被完全吸收。而非金属表面由于没有自由电子,进入表面的能量有一部分会被均匀的释放出来,所以金属度低,但是完全光滑的物体,不会呈现金属那样的镜面效果,而是带有自身的颜色晕染。

-————————————————————————————————————

菲涅尔现象

一般来说漫反射光和镜面反射光之间都是受菲涅尔参数所控制,通过菲涅尔我们可以根据观察的角度得到被反射的能量所占的百分比。利用这个反射比率和能量守恒原则,可以直接得出光线进入物体的部分以及光线剩余的能量。

菲涅尔表示的是看到的光线的反射率与视角相关的现象,视线垂直于表面时反射较弱,而当视线与表面夹角越小时,反射越明显。

img

这就好比我们平时拿吸管插奶茶杯一样,物体的表面就可以看作是奶茶杯的那层膜,光线的光子可以看作是吸管,当你越是斜着插的时候 吸管就越容易被弹开 , 很难插进去,而当你越是竖着插的时候,吸管也就越容易进去。这也就是所谓的菲涅尔现象。

img

而光线的能量进入物体之后会有不同的释放形式,对于金属表面来说,能量进去后,会全部吸收掉。

img

而对于非金属表面来说,能量进去后,只会吸收一部分,余下的部分则会被释放。

img

除此之外对于玉石这类 次表面 散射的 材质来说,能量进去后,会吸收一部分,而余下的能量会从正面出来一部分,从侧面也漏出来一部分。

img

那对于玻璃这类透明的材质来说,能量进去后,吸收的部分很少,正面出来也很少,基本都会从另一面出来。

-————————————————————————————————————

真实感光照

img

PBR的光照不止是直接光,如果只是直接光,那PBR渲染出来的画面,其实和以前并没有多少明显的进步。通常我们会通过叠加计算直接光和间接光得到最终的光照结果,这个结果被称为全局光照,也就是常听到的GI。

说白点就是通过模拟光线的传播路径,将物体反射的间接光纳入计算,从而提高画面的真实感。直接光就是光线从光源直接照射到物体表面上的光,而间接光则是光线经过多次弹射到物体表面上的光。不过因为我们无法完全模拟现实中如此复杂的物理环境,像如阳光的大气折射与场景弹射次数和质量等,所以通常会加入环境光去做近似模拟。

这也是PBR中比较重要的光照部分 - IBL 也就是基于图像的光照。它会把周围的环境颜色完整的保存到一张贴图上,PBR材质会把这个贴图当作光源来进行采样,通过一系列的公式计算得到最终的环境光。如果是金属材质,并且粗糙度比较低,他就能够近乎完整的映射出周围的环境的镜像倒影。不过IBL不处理多次反射,它只是单纯的把环境贴图当成了光源的预积分,这里不做过多赘述,有个大致概念即可。

-————————————————————————————————————

BRDF

学术一点的叫法就是双向反射分布函数,通过入射光方向,反射光方向,平面法线以及微平面粗糙程度作为函数的输入参数,求出最终反射光的强度。

其实我们之前做的Blinn-Phone也可以看作是一个BRDF,但由于Blinn-Phong并没有遵循能量守恒的定律,所以它不被认为是基于物理的渲染。

在早期 PBR模型包含了众多复杂的物理属性,学习成本高、不易于理解,后来在SIGGRAPH 2012,迪士尼提出了Disney BRDF s2012_pbs_disney_brdf_notes_v3.pdf (disneyanimation.com) ,它把众多复杂的物理属性简化成更少更为直观的属性,以极高的易用性、便捷性正式进入了大众的视野,逐渐在业界内被广泛应用,可以说是PBR史上的里程碑。

Unity基于Disney BRDF的启发在GDC 2014 推出了Physically Based Shading in Unity 201403-GDC_UnityPhysicallyBasedShading.key (aras-p.info) ,将传统的渲染工作流升级为基于物理的渲染工作流,美术同学只需要调节Unity内置的Standard Shader,就可以实现绝大多数常见的材质。

那关于BRDF这里我们不去剖析这些复杂的公式,只对它最终的参数进行了解即可~

img

那最终Disney BRDF得到了1个颜色参数以及10个标量参数,颜色参数就是 BaseColor 表面颜色,通常由纹理贴图提供

Subsurface:使用 次表面近似 控制 漫反射的形状

Metallic:金属与非金属两种不同模型之间的线性混合。金属模型没有漫反射的成分

Specular:一般用来控制镜面反射的强度

SpecularTint:镜面反射的颜色啦,不过这个相对过时,通常很少有对美术控制的让步,一般会用于对基础色的入射镜面反射,进行颜色的控制。

Roughness:用来控制表面的粗糙度啦

Anisotropic:各向异性的强度,主要用于头发啊金属之类的材质

Sheen & SheenTint:控制的是光泽的强度和颜色,一般会用于布料

Clearcoat & ClearcoatGloss: 则是清漆强度和光泽度,平时用到的很少,知道有这么个东西就好啦

-—————————————————————————-

Standard Shader

img

RenderingMode:控制的是物体的渲染模式,简单理解就是设定物体是透明的渲染模式还是不透明的渲染模式

Albedo:其实就是材质的基础固有色,你可以给它一张纹理贴图也可以选择单色,当你选择纹理贴图的时候这个调色板则为纹理之上的叠加色

Metallic & Smoothness:这里则是用来控制材质的金属度和光滑度,金属度只是决定了物体最终渲染的结果是以高光为主,还是漫反射为主。而光滑度则是决定了物体的高光部分是否清晰。通常来说不使用贴图的话材质只能具有单一的金属度,这并不理想,毕竟同一个物体表面 可能会具有不同的表面特性,那这里如果你选择了金属度贴图,这里的滑动条会直接隐藏掉,通过贴图所存储的 金属度信息来进行采样计算。光滑度的概念其实和我们之前所提的的粗糙度大同小异,反过来了而已。

Source:可以理解为就是选择光滑贴图,它的数据可以来源于金属贴图,也可以是色彩贴图,这就根据美术来定啦~

NormalMap:法线贴图,它属于凹凸贴图的一种,通常用来给模型增加一些微小的凸起、凹槽和划痕等细节,在逐像素计算光照时,每个像素都会根据该点的法向量来计算最终该点的光照结果,可以通过法线贴图,改变这个点法线方向,影响它的光照结果,进而影响模型表面凹凸感。

HeightMap:视差贴图,又可以称之为高度贴图,是法线贴图的改进版,属于一个经常被忽略的高级功能。大家都知道法线,可以将一个平面做成凹凸不平的效果,但是当视角方向水平于该平面的时候,理论上凸起的部分会遮挡住后面的部分,而法线贴图却没有这个效果,但是高度贴图,就可以。那原理呢,就是根据该点的高度以及该点指向摄像机的向量,计算出一个UV偏移,来影响之后的采样。

Occlusion:环境光遮蔽贴图,通常又会叫AO贴图,在PBR中计算光照的时候,一般直接通过采样IBL来得到环境光,这个环境光是该点上一个半球上的积分。但是因为自身的之间会有凹凸,在凹陷的地方,环境光会被周围给遮挡,所以看起来并不是那么亮,通过AO贴图我们可以让调整环境光的大小,从而达到更真实的效果。

Emission:材质的自发光属性,通常用于控制从表面发出的光的颜色和强度,用的比较多的地方像如霓虹灯、LED屏幕等等。

Tiling & Offset:控制的则是以上所有贴图的Tiling Offset

URP与HDRP

基础概念

Unity的渲染管线大致可分2大类,内置渲染管线 Built-in以及可编程渲染管线SRP,之前的内容所接触到的全都是内置的渲染管线。

Built-in 内置渲染管线

img

它的渲染流程实现,全部都是写在引擎源码里的。而大部分开发者是不会去修改源码的,那这基本上算是不能改动,所以过去的渲染管线对开发者来说,是很难进行定制开发的。而内置渲染管线在一个管线里面支持了所有的二十多个平台,高端的PC支持,很老的手机也要支持,这就应了周星驰电影里的那句我全都要,结果就导致代码越来越臃肿,很难使性能和效果做到最好。

Scriptable Render Pipeline 可编程渲染管线

img

为了解决原本的内置渲染管线不够灵活的这一问题,Unity在2018之后提供了新的渲染系统 - SRP,全称就是Scriptable Render Pipeline

它可以在Unity里通过C#脚本调用一系列的API配置和执行渲染命令 , 来实现一套自定义的渲染流程,你可以根据自己的需求来调整流程或着修改功能。相当于就是帮我们在复杂的底层图形API基础之上,封装了一套通俗易懂的C#API,本质上最后调用的还是底层的图形API。那所谓的图形API就是我们之前提到的像如OpenGL、Direct3D这类的底层渲染API层。

其实SRP简单理解就像是我们的汽车产业,把所有东西都定死的成品车,拆成了各式各样的零部件,让你根据自己的需求来组装。但这就又产生了另一个问题,SRP只丢给了我们一堆散装的零件,而这个拼装过程又非常复杂,很少有人能知晓整个拼装流程。这需要开发者对底层的渲染流程和图形API有足够的认知才可以实现。无疑是增加了开发者的入门门槛,即使费了好大劲组装起来,安全上也无法保证。

URP & HDRP

img

为了解决这一问题,Unity给我们提供了两个可以直接上路的解决方案URP 和 HDRP。他们都是在SRP的API基础之上构建的渲染管线 解决方案,你可以直接开车上路也可以根据自己的需求换个轮胎或者改个内饰。

其中URP 全称是Universal Render Pipeline 通用渲染管线,早期被称为LWRP Lightweight RP 也就是轻量级渲染管线, 在2019.3开始改名为URP,它涵盖了范围广泛的不同平台,是针对跨平台开发而构建的,性能和画质都要比内置管线好,另外还可以进行自定义,实现不同风格的渲染,那这也是目前最推荐大家使用的渲染管线。

而HDRP(High-Definition Render Pipeline)高清渲染管线,则是针对高端设备下的高真实感图形和渲染,像如如 PC、Xbox 和 PlayStation 等高端硬件。

这就像不同的汽车品牌一样,URP就好比专注大众消费的TOYOTA,而HDRP则是为高端精英人士准备的梅赛德斯奔驰。

那除此之外我们还可以在Unity的官方文档看到他们之间详细的对比,像如它们所支持的平台呀光照呀相机等相关得到信息,你都可以轻松的在这里找到想要的答案。

实战案例 - Built-in 升级HDRP

在Window下打开Package Manager,选择Unity Registry,

img

找到HDRRP Package,点击安装就可以啦,

img

然后打开Window>Rendering>HDRP Wizard窗口

img

点击FixAll,Unity则会自动修复HDRP相关的Error

img

修复完成后会弹出一个框,选择CreateOne,创建HDRP Asset资源

img

那现在场景中的材质依然是101品红色,这是因为Shader还是用的内置渲染管线的Shader,

img

所以,这里点击HDRP Wizard窗口下面这栏的第一个按钮,将项目中的所有不兼容材质转换为 HDRP 材质,

img

转换完成后,将Sky Fog Volume添加到场景里用来设置环境照明,

img

打开Window > Rendering > Lighting,选择刚刚Volume的配置文件

img

下面这个静态光照天空属性,选择PhysicallyBasedSky,

img

调节一下光照色温

img

那简单的HDRP升级就完成啦,是不是一下就比原来细致了许多呢

img

实战案例 - Built-in 升级URP

在Window下打开Package Manager,选择Unity Registry,

img

找到URP Package,点击安装就可以啦,不过安装了并不代表我们现在用的就是URP管线,

img

需要在Assets目录下点击Create>Rendering>URP Asset,给它改个名字,创建完成后,

img

点击Editor>Project Settings>Graphics>Scriptable Render Pipeline Settings,选择刚刚创建好的URPAsset

img

然后会弹出一个对话框,点击Continue

img

我们发现设置完成后场景中的材质变成了我们之前所说的101色,那不要担心,接下来在Window>Rendering>Render Pipeline Converter 打开转换器

img

打开后选择Built-in to URP,然后选择要转换的内容

img

勾选完成之后点击左下角按钮进行转换 初始化设置

img

初始化完成之后点击 右下角按钮 进行资产转换,就可以啦

img

细心的小伙伴可能发现之前做的轮廓线不见了,通过官方的文档 URP ShaderLab Pass 标签 | Universal RP | 12.1.1 (unity3d.com) 可以看到,由于在URP里新增了特定的Pass tag,需要给Pass添加特定的Tag LightMode才可以进行多pass渲染

但其实URP 本身的设计思想并不鼓励在shader里写多pass,而是利用 Render feature 实现各种功能,那既然轮廓线只用到了两个pass,所以就简单的给pass加一下tag就好啦,

正常的渲染就用UniversalForward即可,

              Pass
{	
                  Tags
                  {
                         "LightMode" = "UniversalForward"
                  }
              }

像如描边这种需要在渲染对象时绘制额外的 Pass,这里就可以选择SRPDefaultUnlit

              Pass
{	
                  Tags
                  {
                         "LightMode" = "SRPDefaultUnlit"
                  }
              }

实战案例 - 简单URP水面效果

URP除了对管线的升级外,ShaderLab也从原来的CG升级到了HLSL,虽说有向前兼容,原来的CG也不是不能用,但在升级管线之后,Unity SRP 使用的都是HLSL 语言,继续使用CG容易出现许多不知所以的问题。而且URP在ShaderLibrary里面还提供了许多好用的API库

img

那这里通过简单的水面效果来熟悉一下新的HLSL吧~ 其实它与之前的CG大体上还是差不多的,首先新建一个Unlit Shader,把CGPROGRAM替换成HLSLPROGRAM,下面的End也记得换一下

img

把渲染管线标记为URP,由于是水面效果,水是透明的,所以渲染类型也要改成透明,同样把它的渲染队列也标注为透明,最后给它加个透明度混合即可。

img

通常来说水深不同水面的颜色也就不同,因为水分子会吸收通过它的 光的能量,所以这里给它添加两个属性,一个深水色,一个浅水色

_ShallowWater ("shallowColor", Color) = (1.0, 1.0, 1.0, 1.0)
_DeepWater ("DeepColor", Color) = (1.0, 1.0, 1.0, 1.0)

那如何得知水的深浅呢?这里则需要通过拿到场景的深度信息来计算水面的深度,在以往这是一个比较麻烦的步骤,而现在Unity给我们提供了许多好用的函数库,其中有一个就是深度相关的,这里我们直接给它include进来,把之前的CG库替换掉,

#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl"

看到下面的代码报错不要慌,这里fixed给它改成half就可以啦,由于fixed精度过于低,所以hlsl干脆给它丢掉了,当然想要更高的精度也可以使用float。

img

函数报错呢也是因为更新了核心库,所以相关的API也变了

img

那我们该怎么找呢,有点一头雾水。打开ShaderLibary里的核心库

img

搜索关键字,跳转着看看一般就可以找到了,你会发现所有的顶点位置信息都合并到了这一个结构体里啦,

img

声明并且通过get给它初始化一下,这样我们的数据就全都拿好啦,是不是比之前的要方便许多呢,把之前报错的API删掉就可以啦,

VertexPositionInputs positionInputs = GetVertexPositionInputs(v.positionOS);

那深度纹理是全屏纹理,它和屏幕的尺寸相同。而我们希望在当前像素相同的位置对深度进行采样。所以,这里需要要把顶点在屏幕空间位置算好,

v2f vert(a2v v)
{
    v2f o;
    VertexPositionInputs positionInputs = GetVertexPositionInputs(v.positionOS);
    o.positionCS = positionInputs.positionCS;
    o.screenPosition = ComputeScreenPos(positionInputs.positionCS);
    return o;
}

通过引入的库函数来获取屏幕深度,然后把这个深度转换到视图空间。

half4 frag(v2f i) : SV_Target
{
    // 通过深度纹理的采样 计算屏幕深度
    float sceneRawDepth = SampleSceneDepth(i.screenPosition.xy / i.screenPosition.w);
    // 深度纹理的采样结果转换到视图空间下的深度值
    float sceneEyeDepth = LinearEyeDepth(sceneRawDepth, _ZBufferParams);
    return col;
}

由于关心的是这个深度值相对于水面有多深,所以需要把视图深度,减去模型顶点的深度,得到最终水的深度,然后把刚刚的 深浅水颜色 根据水的深度做个lerp,就可以啦

// 最终得到水的深度
float waterDepth = sceneEyeDepth - i.screenPosition.w; 
// 拿到水的颜色
float3 waterColor = lerp(_ShallowWater, _DeepWater, waterDepth);

为了使水多少有点流动感,这里通过noise给它做个简单的漂浮扰动效果,为了让它有流动效果所以给它加个时间和速度进行采样,

float surfaceNoiseSample = tex2D(_SurfaceNoise, i.noiseUV + _Time.y * _MoveSpeed * 0.1).r;

然后根据深度加一圈浮沫,和刚刚的波动混合一下,最后把水的颜色和浮沫的颜色叠加一下,把透明度公开出来,就大功告成啦

// 浮沫
float foam = saturate(waterDepth / _FoamDistance);
float surfaceNoise = smoothstep(0, foam, surfaceNoiseSample) ;
// 混合水面透明度
float4 col = float4(waterColor + surfaceNoise * _FoamColor, _WaterAlpha) ;
return col;

完整代码

Shader "URP/Water"
{
    Properties
    {
        _ShallowWater ("shallowColor", Color) = (1.0, 1.0, 1.0, 1.0)
        _DeepWater ("DeepColor", Color) = (1.0, 1.0, 1.0, 1.0)
        _WaterAlpha("WaterAlpha",Range(0,1)) = 0.5
        
        _SurfaceNoise("Surface Noise", 2D) = "white" {}
        _MoveSpeed("MoveSpeed",Range(0,1)) = 0.5
        
        _FoamDistance("Foam Distance",Range(0,10)) = 0.4
        _FoamColor("FoamColor", Color) = (1.0, 1.0, 1.0, 1.0)
    }
    SubShader
    {
        Tags
        {
            "RenderPipeline"="UniversalPipeline"
            "RenderType"="Transparent"
            "Queue"="Transparent"
        }
        Pass
        {
            Blend SrcAlpha OneMinusSrcAlpha

            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl"
            
            float4 _ShallowWater;
            float4 _DeepWater;
            float _WaterAlpha;

            sampler2D _SurfaceNoise;
            float4 _SurfaceNoise_ST;
            float _MoveSpeed;
            
            float _FoamDistance;
            float4 _FoamColor;

            // 顶点着色器的输入
            struct a2v
            {
                float3 positionOS : POSITION;
                float4 uv : TEXCOORD0;
            };

            // 顶点着色器的输出
            struct v2f
            {
                float4 positionCS : SV_POSITION;
                float4 screenPosition : TEXCOORD0;
                float2 noiseUV : TEXCOORD1;
                float2 distortUV : TEXCOORD2;
            };
            
            v2f vert(a2v v)
            {
                v2f o;
                VertexPositionInputs positionInputs = GetVertexPositionInputs(v.positionOS);
                o.positionCS = positionInputs.positionCS;
                o.noiseUV = TRANSFORM_TEX(v.uv, _SurfaceNoise);
                o.screenPosition = ComputeScreenPos(positionInputs.positionCS);
                return o;
            }
            
            half4 frag(v2f i) : SV_Target
            {
                // 通过深度纹理的采样 计算屏幕深度
                float sceneRawDepth = SampleSceneDepth(i.screenPosition.xy / i.screenPosition.w);
                // 深度纹理的采样结果转换到视图空间下的深度值
                float sceneEyeDepth = LinearEyeDepth(sceneRawDepth, _ZBufferParams);
                // 因为关心的是这个深度值相对于我们的水面有多深,所以需要把视图深度,减去模型顶点的深度
                // 最终得到水的深度
                float waterDepth = sceneEyeDepth - i.screenPosition.w; 
                // 拿到水的颜色
                float3 waterColor = lerp(_ShallowWater, _DeepWater, waterDepth);
                
                float surfaceNoiseSample = tex2D(_SurfaceNoise, i.noiseUV + _Time.y * _MoveSpeed * 0.1).r;
                
                // 浮沫
                float foam = saturate(waterDepth / _FoamDistance);
                float surfaceNoise = smoothstep(0, foam, surfaceNoiseSample) ;
                // 混合水面透明度
                float4 col = float4(waterColor + surfaceNoise * _FoamColor, _WaterAlpha) ;
                return col;
            }
            ENDHLSL
        }
    }
}

ShaderGraph

image-20220915202358287