任何的程序,任何的语言,都有 Helloworld,它是初学者迈入新世界的大门,Unity Shader 也不例外。在实现光彩夺目的画面之前,我们首先来实现一个最简单的 Unity Shader。

Helloworld

以我以往的经验,此时应该直接写出所有的步骤,而不涉及任何的原理。也就是说,只动手,不动脑。

(1)在 Unity 中新建一个场景,操作是点击菜单栏中的 File → New Scene,然后点击 Create,即可创建一个含有摄像头和平行光的场景。

(2)新建一个 Unity Shader,操作是在项目面板中单机创建按钮,然后选择 shader,选任意一个模板都可以。

(3)新建一个材质,类似于上一步,在项目面板中创建。然后把第 2 步创建的 Unity Shader 赋给这个材质,操作是在项目面板中把 Unity Shader 文件拖拽到材质文件上。
(4)在场景中新建一个球体,操作是在层级面板中创建。然后把第 3 步创建的材质赋给这个球体,操作是把项目面板中的材质文件拖拽到层级面板的球体上。
(5)双击打开第 2 步创建的 Unity Shader。删除里面的所有代码,把下面的代码粘帖进去。

Shader "Wuren/Helloworld" {
    SubShader {
        Pass {
            CGPROGRAM

            #pragma vertex vert
            #pragma fragment frag

            float4 vert(float4 v : POSITION) : SV_POSITION {
                return UnityObjectToClipPos(v);
            }

            fixed4 frag() : SV_Target {
                return fixed4(1.0, 1.0, 1.0, 1.0);
            }

            ENDCG
        }
    }
}

保存之后,返回 Unity 会自动编译,就能查看结果了。这段 shader 代码的效果如下图所示:

首先,代码的第一行通过 Shader 语义定义了这个 Unity Shader 的名字。正如 C++ 的函数不一定要传入参数,Unity Shader 也不一定需要 Properties 语义块,我们可以选择不声明任何材质属性。
然后,我们声明了 SubShaderPass 语义块,没有进行任何的渲染设置和标签设置。

接着,就是由 CGPROGRAMENDCG 所包围的 CG 代码片段,这是我们的重点,在 Unity Shader,被 CGPROGRAMENDCG 包围的部分才是我们真正用于计算的代码,在其中可以发挥我们无穷无尽的创意。

在 CG 代码中,首先是两行非常重要的编译指令:

#pragma vertex vert
#pragma fragment frag

它们将告诉 Unity,哪个函数包含了顶点着色器的代码,哪个函数包含了片元着色器的代码,上面这两句对应了后面的 vert 函数和 frag 函数。当然,想要个性化也可以写 #pragma vertex QwQ 并且后面定义一个 QwQ 函数作为顶点着色器。

接下来,我们看一下 vert 函数的定义:

float4 vert(float4 v : POSITION) : SV_POSITION {
    return UnityObjectToClipPos(v);
}

它跟 C 语言很像,函数的输入是 float4 类型,即 4 维向量,变量名是 v,返回值也是 float4 类型的变量。特别的地方在于函数中的 POSITIONSV_POSITION,它们是 CG/HLSL 语言中的语义(semantics),这是不可省略的,它们的存在,给变量赋予了一个身份。在图形学的渲染管线中我们了解到,着色器是有特定的输入输出的,比如顶点的模型空间的坐标会作为顶点着色器的输入,且顶点着色器的输出至少包含顶点的裁剪空间的坐标。我们可以定义顶点着色器的输入和输出包含很多变量,那么哪个变量作为那些特定的输入输出呢?于是,我们用 CG/HLSL 语言中的语义来给变量赋予身份,比如这里 POSITION 将告诉 Unity,变量 v 的身份就是模型的顶点坐标, 于是 Shader 运行时就会把模型的顶点坐标填充到输入参数 v 中,SV_POSITION 将告诉 Unity,顶点着色器的输出是裁剪空间中的顶点坐标。

UnityObjectToClipPos(*) 函数是 Unity Shader 的内置函数,我们用英文来理解它,就是把顶点在模型空间中的坐标变换成裁剪空间中的坐标。在以前,这种操作是通过 mul(UNITY_MATRIX_MVP,*) 这句代码来实现的,也就是左乘变换矩阵,UNITY_MATRIX_MVP 在以前是内置变量。现在只不过是换了种形式,原理都是一样的。

UnityObjectToClipPos(*) 这样的内置函数还有很多,我们以后也会用到,还会用到内置宏。没办法,学习一门语言总要背几个函数。幸运的是,这些内置函数的命名还算好理解,它们使用驼峰式命名法,拆开成几个单词来理解还是比较好记的。

然后我们再来看一下 frag 函数:

fixed4 frag() : SV_Target {
    return fixed4(1.0, 1.0, 1.0, 1.0);
}

非常的朴实无华,没有输入参数,只有 fixed4 类型的返回值。fixed 类型跟 float 类似,也是存储浮点数,只不过存储的位数不同,也就是精度不同,我们之后还会遇到 half 类型也是类似的,都是精度不同的浮点数。我们知道片元着色器的要求是,要输出片元的颜色,同样的,我们用语义 SV_Target 来给 frag 函数的返回值赋予一个身份,告诉 Unity 这个返回值就是颜色向量! 这个 frag 函数直接返回一个全为 1 的向量,也就是纯白色,所以在 Unity 中看到的效果就是一个纯白色的球。

在上面的例子中,在顶点着色器中我们使用 POSITION 语义得到了模型的顶点位置。那么,如果我们想要得到更多模型数据怎么办呢?比如模型的法线、纹理坐标,我们不仅想从模型中得到这些数据,还想把它们传递给片元着色器,那么顶点着色器和片元着色器之间要如何通信呢?

Helloworld Plus

C 语言有 C++,我们 Helloworld 也有 Helloworld++。如果说刚刚说的是真正意义上的 Unity Shader 的 Helloworld,那么接下来要介绍的,就是没那么 HelloWorld 的 HelloWorld。

根据图形学的基础知识,我们知道一个顶点一般有多种属性,包括位置、法向量、纹理坐标。shader 还可能还需要接收一些额外的属性,比如材质本身的颜色、使用的纹理贴图等,这些属性会使用 Properties 语义传递给 Shader,那么我们前面的最简单的 Helloworld 是没办法延伸那些功能的。所以下面将会介绍一个,体系相对完整的 Helloworld,我们仍然先不使用任何的光照计算。

重复之前的步骤,我们要改的只是 shader 代码:
(1)在 Unity 中新建一个场景。
(2)新建一个 Unity Shader。
(3)新建一个材质,然后把第 2 步创建的 Unity Shader 赋给这个材质。
(4)在场景中新建一个球体,把第 3 步创建的材质赋给这个球体。
(5)双击打开第 2 步创建的 Unity Shader。删除里面的所有代码,把下面的代码粘帖进去。

Shader "Wuren/Helloworld Plus" {
    Properties {
        _Color ("Color Tint", Color) = (1.0, 1.0, 1.0, 1.0)
    }
    SubShader {
        Pass {
            CGPROGRAM

            #pragma vertex vert
            #pragma fragment frag

            fixed4 _Color;

            struct a2v{
                float4 vertex : POSITION;
                float3 normal : NORMAL;
                float4 texcoord : TEXCOORD0;
            };

            struct v2f {
                float4 pos : SV_POSITION;
                fixed3 color : COLOR0;
            };

            v2f vert(a2v v) {
                v2f o;
                o.pos =  UnityObjectToClipPos(v.vertex);
                o.color = v.normal * 0.5 + fixed3(0.5, 0.5, 0.5);
                return o;
            }

            fixed4 frag(v2f i) : SV_Target {
                fixed3 c = i.color;
                c *= _Color.rgb;
                return fixed4(c, 1.0);
            }

            ENDCG
        }
    }
}

保存之后,返回 Unity 会自动编译,就能查看结果了。这段 shader 代码的效果如下图所示:

在本篇最开始的 Helloworld 中,我们只渲染了一个纯白的球体,那个 shader 非常的简单,它的顶点着色器只接收了顶点的位置坐标,并且只把顶点的裁剪空间坐标传给片元着色器。显然,巧妇难为无米之炊,如果只使用这么少的变量,再精妙的算法也很难产生炫酷的效果。

我们希望,一个具有高可操作性的 shader,应该可以让我们自由定义着色器的输入和输出,上面的 Helloworld Plus 代码就描述了这么一种自由的结构。

让我们来看代码,首先 Properties 语义中包含了一个名叫 _Color 的属性,在 Properties 语义中定义的属性,是从 Unity 应用传入 shader 的值,选中小球时,可以在检视面板里看到这个属性,并可以修改(如下图)。

我们可以把在 Properties 语义中定义的属性看作是跟材质有关的变量,即不同的材质可能有不同的色泽、亮度,但这些材质共用一个 shader,也就是会使用相同的算法来渲染。shader 代码的 _Color ("Color Tint", Color) = (1.0, 1.0, 1.0, 1.0) 中,"Color Tint" 是我们定义的在检视面板中显示的名称,"_Color" 是 shader 中的变量名,"Color" 是变量类型,"(1.0, 1.0, 1.0, 1.0)" 是显示在检视面板的默认值。

然后我们来看 Pass 语义中的 CG 代码,首先依然是两个 #pragma 预定义,用以声明之后的两个函数分别作为顶点着色器和片元着色器。为了在 CG 代码中可以访问 Properties 语义 中的属性,我们需要在 CG 代码片段中提前定义一个新的变量,这个变量的名称和类型必须与 Properties 语义块中的属性定义相匹配,于是我们增加了一行 fixed4 _Color;。这里可能有疑问,为什么在 Properties 语义中定义过的东西还要重新定义一遍?其实有很多种合理的解释,其中一种就是,Unity Shader 中使用的是 shaderlab 语言,而我们写的渲染代码是 CG 语言,这是两种不同的语言(也可以用 GLSL 语言来替代 CG 语言),所以我们用 CGPROGRAMENDCG 将 CG 代码包裹起来,一种语言中定义的变量当然不能直接在另一种语言中使用。所以,在我们编写 Unity Shader 时,存在着隐式的数据传递,我们需要用重新定义同名变量的形式让 Properties 语义块中的数据传递给 CG 代码。显然,我们必须考虑两种语言的数据类型,就像浮点数不能存储在整型中一样,我们至少知道 Properties 中属性的类型能对应 CG 代码中的那些变量类型,如下表:

ShaderLab 属性类型CG 变量类型
Color, Vectorfloat4, half4, fixed4
Range, Floatfloat, half, fixed
2Dsampler2D
CubesamplerCube
3Dsampler3D

继续看 Helloworld Plus 的代码,我们声明了两个结构体 a2vv2f,它们有什么用呢?前面提过,我们想要自由定义着色器的输入和输出,既然我们的函数只有单个形参和单个返回值,那么用结构体来包含多个变量不就可以了嘛。于是,a2v 的意思是 "application to vertex shader",即顶点着色器的输入,也就是把数据从应用阶段传递到顶点着色器中;v2f 的意思是 "vertex shader to fragment shader",即顶点着色器的输入和片元着色器的输出。

a2v 中我们定义了 3 个变量,它们都有各自的语义来代表变量的身份,在 CG 代码中,一些语义在特别的位置有特殊的作用,POSITION 语义我们前面提过,表示顶点的模型空间的坐标,NORMAL 语义表示法向量,TEXCOORD0 表示纹理坐标,这 3 者作为顶点着色器的输入时都是有特定含义的,因为 Unity 会根据这些语义来把数据填充进变量里。不过,3 个语义用在别的地方时,就没有特定含义。也就是说,在特定的地方使用特定的语义才会产生特定的作用,这些“特定情况”不多,一般需要我们记住。v2f 结构体中使用到的 COLOR0 语义,其实就没有特定的含义。

补充说明一下,填充到 POSITIONNORMAL 这些语义的数据从哪里来,这个其实稍微了解一下就行。在 Unity 中,它们是由材质的 Mesh Render 组件提供的。在每帧调用 Draw Call 的时候,Mesh Render 组件会把它负责渲染的模型数据发送给 Unity Shader。我们知道,一个模型包含了多个顶点,每个顶点又包含了一些数据,例如法线、切线、纹理坐标等,通过语义,我们就可以在顶点着色器中访问顶点的这些模型数据。

在定义了结构体后,着色器的函数就可以将结构体类型作为自己的形参和返回值了,就像代码中的 v2f vert(a2v v)fixed4 frag(v2f i) : SV_Target,相比于本篇最开始的 Helloworld 代码的顶点着色器 float4 vert(float4 v : POSITION) : SV_POSITION,现在不再需要在函数头中声明语义,因为语义的声明已经放在结构体里了。

需要额外提醒的是,一般情况下,变量和形参的命名请与这里的代码保持一致,也就是说,vert(a2v v) 的形参最好就叫 v,返回值最好就叫 ofrag 函数的形参也最好就叫 ia2v 结构体中的变量最好就叫 vertextexcoord,等等。而不要擅自使用 QwQ 之类的变量名,这也许很令人困惑,变量名难道不是咱们程序员爱叫啥就叫啥吗?原来,Unity Shader 给我们提供了一些内置,宏可以用一小段字符串来表示一大段代码,如果某段计算在大部分算法中都被用到,那么它往往被设计成宏,而宏就相当于代码替换,在被替换的代码中会直接出现变量名,举个例子,宏 WUREN 会被替换成 a = a + 1,假如我们预先定义的变量名不是 a,那么使用这个宏就会报错。所以,并不是说变量名一定要统一,只是说,为了之后使用 Unity Shader 的内置宏来简化我们的代码,我们往往会规范自己的变量名。当然,如果你就是不用 Unity Shader 的内置宏,那么无论用什么变量名都无所谓。

至于 Helloworld Plus 中使用到的渲染算法,则非常简单,只是把顶点的法向量归一化作为顶点的颜色,然后通过插值得到片元的颜色(插值是在顶点着色器和片元着色器之间自动进行的,代码中不体现)。有 c 语言基础的话应该马上就能理解代码。最后呈现的效果就是一个五彩斑斓的球体。

结语

本篇讲述了 Unity Shader 中最基础的语法理解,有如万丈高楼的地基一般。基本上,每一个有点用的 shader,都会用到本篇所提到的所有概念。在写这篇 Helloworld 的时候,其实我已经看了很多进阶知识,但越往后学,越觉得遗忘,逐渐看不懂代码,直到我回来总结了两篇博客(这篇和另一篇),有种“拨开云雾见月明”的通透感。

另外总结一下本篇要背诵的知识,讲过 UnityObjectToClipPos(*) 函数,讲过一些特殊位置会发挥特殊作用的语义,包括 POSITIONNORMALTEXCOORD0SV_POSITIONSV_Target

网上能查到的知识很零碎,讲基础的更是少之又少,本篇博客既是我的一篇笔记,也是我希望帮助初学者的一点努力。最后希望你喜欢这篇博客~