前言
此文及专栏系以《Shader入门精要》为基础整理的Unity Shader学习笔记,尽量以初学者视角还原(其实也就是半年前),错误还需指正。
本文是实操部分的第四个Shader,即基础纹理Shader,涉及的Shader相关基础概念可能不再赘述。本专栏仍在持续更新中,可作为相关Shader类型的查阅,也应可作为新手学习之用。
关于纹理类型Shader
其实,贴一张简单的图到3D物体上——这是计算机图形渲染最早的需求之一,也是众多进阶Shader的基础。在游戏制作过程中,纹理多数情况下是美术所关心的,其往往是通过uv坐标系来定义的。啥是uv呢?你可以理解为一种特殊的坐标系,是图像坐标和模型顶点的对应关系。其中,u是横向,v是纵向坐标,一般被归一化为[0,1]区间。
从网络上找张图帮助我们理解,这是被展开后的UV,因为负责贴图的美术不可能总是对着一个3d的模型进行绘制、对应之类的工作,3D模型一般都会做展UV、调整UV这样的工作,创造一个能够展示为平面的UV坐标系方便操作。在这个平面UV内,坐标就是我们说的UV坐标啦。
作为一个美术背景的读者,个人以为了解UV对写Shader还是非常重要的。话不多说,将一张简单的贴图贴到模型上(好像一张打印着贴图的纸包裹上去),就是我们今天要做的事。关于贴图,网络上其实有很多资源,自行搜索木纹、墙纸、建筑贴图,直接用png格式导入unity即可,或者从这个网站去找找专业的CG资源texture贴图网站
单张纹理Shader
我们在unity里新建一个unlit shader,其实默认创建的就是一个贴图纹理Shader,不过,默认的不够完整,并且使用了过多封装好的用法,这里我们还是使用如下代码:
Shader "Unlit/TextureShader"
{
Properties
{
_Color ("Color Tine",Color) = (1, 1, 1, 1) //这是基础色彩
_MainTex ("Texture", 2D) = "white" {} //这是传入的纹理图片
_Specular ("Specular", Color) = (1, 1, 1, 1)//这是高光色彩
_Gloss ("Gloss",Range(8.0, 256)) = 20 //这是光泽度
}
SubShader
{
Pass
{
Tags{"LightMode" = "ForwardBase"}
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Lighting.cginc"
fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST; //这是纹理的UV偏移属性
fixed4 _Specular;
float _Gloss;
struct a2v
{
float4 vertex : POSITION;
float3 normal : NORMAL;
float2 texcoord : TEXCOORD0;
};
struct v2f
{
float3 worldNormal : TEXCOORD0;
float3 worldPos : TEXCOORD1;
float2 uv : TEXCOORD2;
float4 pos : SV_POSITION;
};
v2f vert (a2v v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
o.uv = TRANSFORM_TEX(v.texcoord, _MainTex); //对UV按照输入值进行变换
return o;
}
fixed4 frag (v2f i) : SV_Target
{
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
fixed3 albedo = tex2D(_MainTex, i.uv).rgb * _Color.rgb;
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
fixed3 diffuse = _LightColor0.rgb * albedo * max(0,dot(worldNormal, worldLightDir));
fixed3 viewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
fixed3 halfDir = normalize(worldLightDir + viewDir);
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(saturate(dot(worldNormal,halfDir)),_Gloss);
return fixed4(ambient + diffuse + specular, 1.0);
}
ENDCG
}
}
Fallback "Specular"
}
前几次文章是把代码按结构切成了块分析,单独的代码块有很多不规范的地方,这次按照Shader 的结构分解,一些符号什么的就跳过了。
Properties块
Properties
{
_Color ("Color Tine",Color) = (1, 1, 1, 1) //这是基础色彩
_MainTex ("Texture", 2D) = "white" {} //这是传入的纹理图片
_Specular ("Specular", Color) = (1, 1, 1, 1)//这是高光色彩
_Gloss ("Gloss",Range(8.0, 256)) = 20 //这是光泽度
}
properties块是用来声明传入的外部变量的,用户可以在Material的inspector面板进行修改,如本Shader就可以在该面板指定Texture,用一张2D平面图即可。
本Shader仍然需要漫反射基础色彩(可以控制物体的基础色彩,相当于底)和高光色彩,另外还有高光反射中使用的光泽度也一并传入,比较重要的是Texture格式的变量_MainTex,它的格式是2D也就是2D图片,默认值是white,全白色贴图。
Tag标签和相关声明
Tags{"LightMode" = "ForwardBase"}
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Lighting.cginc"
fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST; //这是纹理的UV偏移属性
fixed4 _Specular;
float _Gloss;
接下来我们声明Shader的Tags、引用变量和使用库。首先我们声明Tags,LightMode标签这里用于定义整个Pass块在Unity渲染流水线中的角色,值是ForwardBase(具体作用是一种光照模式,如果我们不声明这个Tag会导致一些内置变量无法使用)
我们指定顶点着色器和片元着色器函数,vert和frag,这里不再进行赘述。我们包含unity的内置文件Lighting.cginc,该文件包括一些光照渲染中的常用参数,如果缺失会导致接下来内置变量无法使用。
接下来一段将我们在properties块中传入的变量重新声明,注意这里使用了_MainTex_ST变量,它的名字是纹理贴图_ST,代表纹理的UV偏移属性,通俗解释就是这张贴图被包裹在你的模型上时,是否进行了缩放和移动,其4个分类分别是uv方向的缩放和偏移。看下面这张图,tilling是缩放,offset是偏移,可以在材质的inspector面板中手动调节,如果你尝试改变会发现,这张贴图“移动”或者“变大变小”了。事实上,这个变量是自动包含在我们的贴图2D变量下的,为了使用我们专门进行声明。
输入输出结构体
struct a2v
{
float4 vertex : POSITION;
float3 normal : NORMAL;
float2 texcoord : TEXCOORD0;
};
struct v2f
{
float4 pos : SV_POSITION;
float3 worldNormal : TEXCOORD0;
float3 worldPos : TEXCOORD1;
float2 uv : TEXCOORD2;
};
还是a2v和v2f,我们熟悉的顶点着色器输入和输出结构体。
在输入结构体也就是a2v中,我们定义了以下变量:顶点坐标vertex,用POSITION语义传入;法线normal;texcoord用于存储模型的纹理坐标,用TEXCOORD0语义传入(相似语义还有TEXCOORD1、2、3等等,我对这一点非常疑惑,查了一下才明白,这其实是同种模型的不同UV,是美术定义的【美术:我没有别瞎说,其实美术也不一定知道这事】,用于贴图的映射和改变,例如左右脸贴图就可以用同种贴图不同UV)
输出结构体这里,首先是要传入的坐标pos,其语义固定为SV_POSITION;
顶点着色器
v2f vert (a2v v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
o.uv = TRANSFORM_TEX(v.texcoord, _MainTex); //对UV按照输入值进行变换
return o;
}
接下来我们看顶点着色器的代码,其函数结构不再解释。
首先用UnityObjectToClipPos将输入的顶点坐标,裁剪到裁剪空间中(没看过前面文章的可以理解为将不显示的坐标排除);用内置函数UnityObjectToWorldNormal将法线转换为世界坐标系;将顶点坐标vetex与内置的转换矩阵unity_ObjectToWorld相乘,转换成世界坐标系;最后,对uv进行处理,使用输入的uv也就是我们之前定义的_MainTex.ST来进行偏移和缩放,用的函数是内置的TRANSFORM_TEX,它的两个参数是顶点纹理坐标和纹理名,能够自动将uv按照输入值进行变换(其实也可以手动四则运算,不过既然unity提供了,何必呢)
片元着色器
fixed4 frag (v2f i) : SV_Target
{
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
fixed3 albedo = tex2D(_MainTex, i.uv).rgb * _Color.rgb;
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
fixed3 diffuse = _LightColor0.rgb * albedo * max(0,dot(worldNormal, worldLightDir));
fixed3 viewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
fixed3 halfDir = normalize(worldLightDir + viewDir);
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(saturate(dot(worldNormal,halfDir)),_Gloss);
return fixed4(ambient + diffuse + specular, 1.0);
}
片元着色器结构和参数还是不解释,首先拿到顶点着色器输出的v2f,计算世界坐标系下的法线和光照方向并用normalize归一化,其中UnityWorldSpaceLightDir(i.worldPos)是计算worldPos这一点到光源的方向向量。
然后,我们用tex2D对纹理进行采样。什么是采样呢?我们这里毕竟是片元着色器,是显示中的片元,并不对应纹理的每个像素点,那么就有必要对这个片元应该显示的像素点采样得出一个唯一结果。tex2D就是干这个事,它的头一个参数是采样纹理,第二个则是uv坐标,返回值是计算得到的色彩信息,与基础色彩_Color相乘,就是片元着色器计算出的纹素(可以理解为最终色彩的一部分——表面颜色)。
将纹素与环境光也就是UNITY_LIGHTMODEL_AMBIENT的xyz值相乘,得到环境光部分;回过头,我们用纹素albedo计算出diffuse也就是漫反射结果,这里其实是把漫反射Shader里_LightColor0.rgb * _Diffuse.rgb * halfLambert中的diffuse信息换成了我们的纹素,也就是默认的漫反射纹理换成了我们计算后的结果。
接下来的活儿首先是用UnityWorldSpaceViewDir(类似于UnityWorldSpaceLightDir)得到观察方向向量,然后仍然是狸猫换太子,我们高光反射的计算式是_LightColor0.rgb * _Specular.rgb * pow(saturate(dot(reflectDir, viewDir)),_Gloss),那么我们这里需要变换的是viewDir,这里用一个非常粗暴的方式,即halfDir(光照入射向量和观察向量的均值)替代viewDir,计算出高光反射结果。
最后,纹素(环境光)、漫反射、高光三个结果相加,加上第四个分量1.0,我们的计算就大功告成了!
总结
看看结果?左边的就是我们刚刚写的Shader的渲染结果,纹理我用的网上下载的墙壁纹理,可见效果还是不错的,因为本次没有使用半兰伯特修正,后面是全黑的有点难看,实际运用可以改正。
很多人学到这里可能还是没有弄清楚我们的着色器为什么这样写、为什么又这样返回,事实上,我也是如此。不过写Shader是一个在高度封装好的平台上的工作,我觉得我们应该理解:顶点着色器和片元着色器是我们编辑的重点,它们分别对应模型顶点的计算和屏幕显示结果片元(或理解为像素)的计算,这些乱七八糟的计算只是渲染很少的一部分,但却是我们对图形渲染过程作改变非常好用的部分。那么我们只需要提出我们需要修改的变量,再指定他们的运算,再一股脑返回给unity就好——这样想或许会让你省心些,当然,更深入的学习必然是要看看图形学的。