UnityShader 从入门到盖棺(二)
前言
这是我入门学习笔记的第二篇。这篇开始,我们会逐渐做一些更贴近实际的效果。而不是和上周那个球体球体,随便输出点莫名其妙,妙不可言的颜色(✿◕‿◕✿)。
另外,本章节可能还会稍微提及上一章的代码,但是后续可能会逐渐跳过大量的代码来篇幅。毕竟我想让读者关注到本章更核心的内容,而不是一直重复某些事情。如果读者想要获取完整代码甚至整个场景,可以到我的github上面去下载。
贴图和lambert光照模型
回到正文,如标题说的,本章分享一下贴图,以及一个很简单的光照模型-lambert光照模型。我们还是老套路,先把最终效果砸出来。
可以看到,这是一个简单的房子(千万不要和我说丑,说就是一巴掌)。我们第一步,先把房子搭建起来。很简单,只需要创建6个平面。可以参考我的层次关系以及每个平面的变换。
现在每个平面使用的都是默认材质Default-Material。看起来场景样子是这样的。
现在我们先不讨论这个默认材质,我们需要创建自己的材质然后替换掉它。
第二步,创建一个Unlit_Shader文件, 命名为2_DiffuseWall。打开UnityShader文件后,把里面的内容全删了,并且复制下面一大段代码进去保存。这里除了Cull Off之外用到的都是上一篇讲到的操作和代码,之后的教程,我会尽量跳过这些内容。
Cull Off, 是指禁用掉面剔除。什么是面剔除,拿一个正方体来说,我们正常能看到的最多是一个正方体的3个面(你可以试下你能不能成为那个不正常的人),所以剩余三个面就没有绘制的必要了。因此我们会把它剔除掉,具体剔除规则是通过整个面的顶点绘制顺序来判断正反面,我们通常把反面剔除掉。而Plane这个默认对象,他只有单面,因此,如果我们到背面去看他,他就会消失不见,原因就是因为被Unity剔除掉了。
Shader "Unity Shader/C2/2_WallDiffuse" {
Properties {
_MainColor ("color tint", COLOR) = (1.0, 1.0, 1.0, 1.0) // 物体的主颜色
}
SubShader {
Pass {
Cull Off // 把面剔除禁用
CGPROGRAM // CG代码块
#pragma vertex vert // 声明vert函数为顶点着色器
#pragma fragment frag // frag为片段着色器
fixed4 _MainColor;
struct a2v { // 顶点着色器输入结构体
float4 vertex : POSITION; // 顶点物体空间位置
};
struct v2f { // 片段着色器输入结构体
float4 pos : SV_POSITION; // 顶点裁剪空间位置
};
v2f vert(a2v v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex); // MVP变换
return o;
}
fixed4 frag(v2f i) : SV_Target {
return _MainColor; // 输出物体主颜色
}
ENDCG
}
}
Fallback "Diffuse" // 备胎
}
为了应用上我们的Unity Shader,我们还得创建一个材质。每个shader和材质都是一一对应的。后面我说创建一个材质,就意味着创建一个和UnityShader同名的材质,并且选中对应的Shader(上一节已经讲过这个操作)。操作完之后,把材质拖到6个plane中,我们可以看到如下效果。
可以看到这个效果非常糟糕,纯白色一片,差点亮瞎我家小致的狗眼。我们希望这是一堵砖墙,而不是这样一个白色平面,毫无细节。因此,我准备了一张砖的图片(来自Shader入门精要),接下来我们把这张图片贴到我们的平面上。
第三步,修改着色器代码,增加一个2D纹理属性,用来传入我们的砖墙纹理。
Properties {
_MainColor ("color tint", COLOR) = (1.0, 1.0, 1.0, 1.0)
// 2D就是一个二维纹理变量。
// "white"是纹理的默认值, 表示一张纯白色图片
_MainTex ("main tex", 2D) = "white" {}
}
我们还需要在CG代码块中再声明一次这个变量,供后续着色器代码使用。你们应该知道声明在哪吧,知道吧,知道吧~。不知道的参考下_MainColor变量,然后回顾下上篇讲到的Shader结构。也可以直接拿github上源码参考。后面的代码不知道放哪我就不重复了哈。
// sampler2D是一个二维纹理采样器。
// sampler2D就是对应上面的2D属性。
sampler2D _MainTex;
上面提到一个二维纹理采样器。什么是采样器呢。首先采样是指,我们拿到一个片段后,需要一个映射,从一张纹理里面找到这个片段所属的位置,并且把其中的内容读出来。如果这是一张颜色纹理,像我们现在的砖墙,那他的内容对应的就是颜色。相当于我们通过纹理采样过程知道了一个片段的颜色。而辅助我们完成这个采样过程的就是采样器。
二维纹理采样原理也很简单。我们把纹理放在一个二维坐标系里,以左下角为原点(0,0),右上角为(1,1)。水平轴称为u轴,垂直轴成为v轴。那我们给定一个片段一个坐标(0.5,0.5), 我们就可以把这个坐标映射到纹理的中间。
来总结一下,上面提到的坐标系就是纹理坐标系,也叫UV坐标系。而(0.5,0.5)这个坐标就是片段的纹理坐标,也叫UV坐标。如果还是有点不太理解的话,可以看看learnopengl里面更详细的解释。
好了,第四步,我们为了让我们的平面对maintex纹理采样,我们需要给片段着色器输入纹理坐标uv。而这个坐标,Unity会传给我们(模型导入后就会确定下来)。
struct a2v {
float4 vertex : POSITION;
float2 texcoord : TEXCOORD0; // 顶点着色器输入纹理坐标
};
struct v2f {
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0; // 片段着色器输入纹理坐标
};
v2f vert(a2v v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
// 我们先直接把Unity给我们的纹理坐标传给片段着色器
o.uv = v.texcoord;
return o;
}
fixed4 frag(v2f i) : SV_Target {
fixed4 albedo = tex2D(_MainTex, i.uv); // 采样
return albedo; // 输出物体主颜色
}
上面核心代码就是tex2D函数。这个函数第一个参数是纹理采样器,第二个参数是纹理坐标,返回给我们的就是采样结果。
第五步,我们可以看到修改之后的材质里面,我们的纹理属性对应有一个框框。我们把我们的砖墙拖进那个框框里面。
可以看到现在场景变成这样!
是不是瞬间就不一样了。这张颜色纹理为我们补充了大量的细节。如果我们要用纯颜色实现这个,我们需要好多好多个小平面,这就意味更多的人力成本,更多的顶点和更多的运行消耗。
细心的人会发现,我们纹理属性那里除了一个纹理框框,还有4个属性。
这四个属性是用来调节我们纹理坐标的平移缩放的。但是现在大家怎么改都没作用。是因为我们代码里面没用到它,下面我们来完善一下代码。
首先,增加一个变量声明_MainTex_ST。注意,我们不需要在属性块里面填入。我们在属性块声明了一个2D变量后,Unity就会把这个平移缩放赋值给我们在shader代码中声明的纹理名字+_ST的变量。其中S代表缩放,T代表平移。
sampler2D _MainTex;
float4 _MainTex_ST;
然后我们在顶点着色器中,把uv的赋值代码修改一下,之前是直接赋值,现在我们加上传入的平移缩放参数。
o.uv = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;
// unity的头文件 UnityCG.cginc里面提供了TRANSFORM_TEX函数帮助我们完成这个操作
// #include "UnityCG.cginc" // 定义在 #pragma的后面
// o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
// 之后的代码都会采用这种,因为内置就是香,会帮我们考虑一些跨平台问题
好了,现在回到场景里面,调整一下4个参数,看看会发生什么吧!
大家应该发现,尽管我们的场景中有一个光源(默认的平行光,如果你没有,肯定是你多手删掉了),但是这个光源对我们的房子没有任何影响。理论上,面对光源的墙应该亮一点,而背对光源的墙应该暗一点。
为了实现这个效果,接下来第六步我们简单引入一个光照模型,lambert光照模型。
所谓的光照模型,其实就是一个公式,就是根据这个公式去计算,我们就能获得一个类似光照的效果。这个公式不一定要很正确(现实的光照计算是很复杂的),只要这个公式能以很高的效率带给我们相对较好的效果,那么就是一个好的光照模型。不同情境下,我们会应用不同的光照模型。
先看看lambert光照模型
l = ambient + diffuse;
兰伯特告诉我们,光照里面有环境光和漫反射光两个分量组成。
环境光,大家会发现晚上在一个几乎看不到光源的地方,还是能看清物体,这就是环境光的影响。事实上,环境光是所有光源在多次反射后,在整个环境都留下了它们的踪迹。没错!就是光污染。
漫发射光,就是光线在照射一个物体表面的时候,会向四面八方均匀的反射光线。而这个反射光线的强度和光线照射平面的角度相关。入射光线和法线的夹角越小,反射光线的强度越大。
反射光强 = 入射光强 * dot(入射光线, 表面法线)。
dot是点乘的意思。当入射光线和表面法线都是单位向量(长度为1)的时候,点成就代表他们夹角的余弦值,刚好是我们需要的一个系数。
好了,根据上面简单提到的理论,我们来实现lambert光照模型。首先我们需要一个片段的法线和对应的入射光线方向。这两个属性我们都可以通过unity提供的一些接口或者变量中得到。我们直接看代码
#include "UnityCG.cginc"
// 光照相关头文件,可以帮我们计算入射光线
#include "Lighting.cginc"
struct a2v {
float4 vertex : POSITION;
float2 texcoord : TEXCOORD0;
float3 normal : NORMAL; // Unity提供的顶点法线向量
};
struct v2f {
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
float3 worldPos : TEXCOORD1; // 世界空间下的片段位置
float3 worldNormal : TEXCOORD2; // 世界空间下的法线向量
};
v2f vert(a2v v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
o.worldPos = mul(UNITY_MATRIX_M, v.vertex.xyz); // 通过模型变换,求出世界空间的顶点位置
o.worldNormal = UnityObjectToWorldNormal(v.normal); // 求出世界空间的法线位置
return o;
}
fixed4 frag(v2f i) : SV_Target {
// 把插值结果单位化,注意一定要变成单位向量,否则后面计算会不完整。
float3 worldNormal = normalize(i.worldNormal);
// 通过UnityWorldSpaceLightDir求出光照方向
float3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos);
fixed4 albedo = tex2D(_MainTex, i.uv);
可以看到上面我们获得了世界空间下的法线向量和光线的入射方向向量。接下来我们开始光照模型的计算。
// 环境光
// 内置变量UNITY_LIGHTMODEL_AMBIENT为我们提供了环境光的光强
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT * albedo.rgb;;
// 漫反射光
// saturate可以把输入参数截断到[0,1]区间,相当于 min(0, max(1, x))
float lambert = saturate(dot(worldNormal, worldLightDir));
// 内置变量_LightColor0为我们提供了光线的颜色
fixed3 diffuse = _LightColor0.rgb *albedo.rgb * lambert;
然后我们把得到的两种光照结果应用在lambert光照模型上来计算最终的结果,并作为片段着色器的输出。
这时候我们回到场景看看效果,法线向光面更亮,背光面更暗了!
最后,我觉得地板用砖好像不太好,所以我多创建了一个材质2_FloorDiffuse, 然后在编辑面板里换了一张水泥贴图。把材质拖到地板上,就是我们的最终效果啦!ヾ(≧▽≦*)o
结语
第二篇的分享结束了,不知道你们的是不是有很多问号。我还是强调一下,因为很多理论上的知识我无法展开普及,例如什么是单位向量,什么是点乘。又或者光线颜色和片段颜色相乘的意义是什么等等。
以上这些我都认为大家是知道的,但如果实在有什么地方晦涩难懂或者我说的明显有问题。再或者你真的很萌新,但又很想学好。都可以在评论里面说一下,毕竟我也是基于入门做分享的,希望大家和我都能走到盖棺那一步。
好啦,最核心的支持作者环节来啦!!!
链接
上一篇: 设计模式——中介者模式
下一篇: 行为型模式之中介者模式