在很多学习 Shader 的资料上,都是以标准光照模型开始,也就是常见的漫反射和高光反射光照模型,我的这系列也不列外,该篇先从漫反射开始。
漫反射是生活中很常见的一种光线反射现象,光线照射在物体表面时,会向各个方向进行散射。一个点的漫反射由入射光线的角度决定,入射角越大,视觉观感得到的光强越弱。
漫反射,截自维基百科在 Shader 中,决定最终视觉效果的是物体表面的颜色、光照颜色、法向量和入射光线方向(注意这里的入射光线是从表面顶点指向光源)
物体表面颜色和光照颜色很容易理解,那么法向量和入射光线方向是干嘛用的呢?
上面谈到,漫反射由入射光线的角度决定,而这里决定的就是在屏幕显示时对应颜色的强弱,一个简单的例子就是,一个白色的物体,在光线直射的地方,白色会很亮,接近纯白,而在光线侧向照射的地方,白色就没那么白了,会带些许的阴影,在图形显示上颜色会变成灰色甚至是黑色。
这里所说的强弱,由法线和入射光线决定,两向量形成的夹角越小,则越接近直射效果,而当夹角大于或等于90°时,表面接受不到光照,呈黑色。衡量这个夹角,我们使用余弦值,即反射光线强度与表面法线和光源方向夹角的余弦值成正比。
于是有了如下的漫反射计算公式:
![][0]
[0]:http://latex.codecogs.com/png.latex?diffuse=(lightColorM_{diffuse})max(0,\overrightarrow{n}\cdot\overrightarrow{l})
其中,lightColor为光照颜色,Mdiffuse为材质颜色,n 和 l 分别为表面法向量和光源方向。我们可以这样理解,前面括号内的部分是要显示的目标颜色,后面的最大值计算,是为了得到要显示的颜色强度,而取最大值,是为了避免因为夹角大于 90° 出现负值的情况,我们在开发中,可以使用 saturate() 函数来代替最大值函数,该函数会把传进的数值截取到 [0,1] 之间。然后,这里为什么单两个向量点积就能得到余弦值呢?
我们知道,点积展开公式如下:
![][1]
[1]:http://latex.codecogs.com/png.latex?\overrightarrow{n}\cdot\overrightarrow{l}=\left|n\right|\left|l\right|cos\theta
在计算时,我们为了方便,会把两个向量做归一化处理,也就是模为1,由此以上点积公式就变成了:
![][2]
[2]:http://latex.codecogs.com/png.latex?\overrightarrow{n}\cdot\overrightarrow{l}=cos\theta
理论工作做完,就到了代码部分了:
逐顶点光照
逐顶点光照,顾名思义,就是对模型的顶点进行光照着色,这种方式在性能上占有,但是效果没有逐像素光照细腻
Shader "Ojors/DiffuseShader" {
Properties {
_Diffuse ("Diffuse", Color) = (1,1,1,1)
}
SubShader {
Pass {
Tags {"LightMode" = "ForwardBase"}
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Lighting.cginc"
fixed4 _Diffuse;
struct vertIn {
float4 vertex : POSITION;
float3 normal : NORMAL;
};
struct vertOut {
float4 pos : SV_POSITION;
fixed3 color : COLOR;
};
vertOut vert (vertIn i) {
vertOut o;
o.pos = mul(UNITY_MATRIX_MVP, i.vertex);
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.rgb;
fixed3 worldNormal = normalize(mul(i.normal, (float3x3)unity_WorldToObject));
fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(worldNormal, worldLightDir));
o.color = ambient + diffuse;
return o;
}
fixed4 frag (vertOut i) : SV_Target {
return fixed4(i.color, 1.0);
}
ENDCG
}
}
FallBack "Diffuse"
}
对于上篇已经讲述过的内容,这里就不再赘述,我们只关注实现部分:
首先是属性:
Properties {
_Diffuse ("Diffuse", Color) = (1,1,1,1)
}
这里定义了一个颜色属性,默认值是白色,表示使用该 Shader 的材质的颜色
#include "Lighting.cginc"
这里包含的光照文件里定义了我们需要获取的光照颜色,其实打开这个文件的话大家会发现,这里面定义了很多很实用的光照模型的计算函数,如果我们只是需要用到里面的变量,比如:_LightColor0,我们可以只包含 UnityLightingCommon.cginc 这个文件即可。
struct vertIn {
float4 vertex : POSITION;
float3 normal : NORMAL;
};
struct vertOut {
float4 pos : SV_POSITION;
fixed3 color : COLOR;
};
这里定义了两个结构体,一个是用于接收 Unity 外部输入的值,我们需要获取顶点以及表面法线;另一个是用于存储从顶点着色器到片段着色器需要传递的值,我们需要传递空间转换后的顶点坐标以及要输出的颜色。(注意:对于输入结构体和输出结构体顶点位置的语义,输入结构体我们使用 POSITION,而输出结构体我们使用 SV_POSITION,我查阅资料得知对于存储顶点的寄存器,输入和输出是不同的,所以我们一般会在两个结构体中对顶点定义不同的语义,但我在 Unity 5 中试过都用 SV_POSITION,其实并不会报错,我猜测可能是 Unity 为我们进行了处理,但为了兼容性和规范性,我们还是对输入和输出结构体定义不同的语义吧。)
vertOut vert (vertIn i) {
vertOut o;
o.pos = mul(UNITY_MATRIX_MVP, i.vertex);
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.rgb;
fixed3 worldNormal = normalize(mul(i.normal, (float3x3)unity_WorldToObject));
fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(worldNormal, worldLightDir));
o.color = ambient + diffuse;
return o;
}
这里是逐顶点光照的计算过程:
首先要做的当然是顶点坐标的变换,然后提取环境光。
【这里要说一下什么是环境光:我们在现实中看到的所有物体,都受其他物体所反射的颜色的影响,比如一个场景:红色的地毯上摆放着一个茶几,茶几除了接收到自然光的照射外,还受到地毯所反射的颜色的影响,使其带有淡淡的红色。而环境光在 Unity 中可以通过 UNITY_LIGHTMODEL_AMBIENT 变量获取到】
然后是对法线进行从模型坐标系到世界坐标系的转换(只有在同一坐标空间下,点积才有意义),前面的篇章已经提到法线的变换,需要使用顶点变换矩阵的转置矩逆阵进行变换,在这里,我们使用法线右乘矩阵 unity_WorldToObject 来得到转置逆矩阵的效果(最近更新了Unity 5.4, 发现 _WorldToObject 矩阵命名变成了 unity_WorldToObject,这里需要注意!!!)。
然后获取当前光照的方向:_WorldSpaceLightPos0
当然,上述操作都要把向量进行归一化处理。
最后就是进行漫反射的计算了,套用上面讲到的公式即可,返回跟环境光混合后的最终颜色。
fixed4 frag (vertOut i) : SV_Target {
return fixed4(i.color, 1.0);
}
对于逐顶点光照,片段着色器的代码非常简单,只需要把顶点着色器计算得到的颜色结果输出即可。
逐像素光照
逐顶点光照因为是基于顶点来计算的,所以对于顶点数较少的模型,出来的效果可能不会很好,可能会产生锯齿,而逐像素光照可以解决锯齿问题,但因为是逐像素的计算,性能较逐顶点计算会较低,这里的取舍要看具体需要。
逐像素光照和逐顶点光照的计算方式其实大同小异,只是把顶点进行的光照计算移到了片段着色器中:
首先要说的是结构体:
struct vertIn {
float4 vertex : POSITION;
float3 normal : NORMAL;
};
struct vertOut {
float4 pos : SV_POSITION;
float3 worldNormal : TEXCOORD0;
};
这里的第二个结构体与逐顶点计算的稍微不同,worldNormal 量获取的是在世界坐标系下的法线,属于我们自定义的数据。对于自定义的数据,我们习惯把其语义定义成 TEXCOORD,但其实除了输入结构体中的特定数据、POSITION 和 SV_POSITION 外,输出结构体我们用什么语义都是可以的,其实质是 GPU 中的寄存器,但是为了规范书写,还是建议使用 TEXCOORD 来存储自定义数据。
然后是顶点着色器:
vertOut vert(vertIn i){
vertOut o;
o.pos = mul(UNITY_MATRIX_MVP, i.vertex);
o.worldNormal = normalize(mul(i.normal, (float3x3)unity_WorldToObject));
return o;
}
这里简单地把法线从模型坐标转换到了世界坐标。
片段着色器:
fixed4 frag(vertOut i) : SV_Target{
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.rgb;
fixed3 normal = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(normal, worldLightDir));
fixed3 color = ambient+ diffuse;
return fixed4(color, 1.0);
}
基本与前面讲到的相同。值得注意的一点是,法线到了片段着色器需要再一次进行归一化处理,原因是法线从顶点着色器到片段着色器的过程中,法线会进行插值处理,得到的法线不一定是单位长度的法线了,对后面的计算会产生影响。
Half Lambert(半兰伯特)光照模型
对于上述的光照,在背光面会产生一个问题,没有光照的地方会表现成纯黑,会失去一些模型的细节,而为了弥补这个缺陷,有了 Half Lambert 光照模型。(注意:该模型没有物理依据,只是一个视觉的加强技术)
Half Lambert 光照模型计算公式:
![][4]
[4]:http://latex.codecogs.com/png.latex?diffuse=(lightColorM_{diffuse})(\alpha*(\overrightarrow{n}\cdot\overrightarrow{l})+\beta)
原来的 max 部分被括号部分代替
其中:alpha 和 beta 分别代表阴影面和光照面的权重,alpha 越大,阴影面越大,alpha+beta = 1。
以逐像素光照为例,与上面的 Shader 对比,该光照模型区别的代码是片段着色器:
vertOut vert (vertIn i) {
vertOut o;
o.pos = mul(UNITY_MATRIX_MVP, i.vertex);
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.rgb;
fixed3 worldNormal = normalize(mul(i.normal, (float3x3)unity_WorldToObject));
fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * (dot(worldNormal, worldLightDir)*0.7+0.3);
o.color = ambient + diffuse;
return o;
}
关键一句:
fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * (dot(worldNormal, worldLightDir)*0.7+0.3);
可以看到,我这里把 alpha 设为 0.7,beta 设为0.3,看图对比就能看出区别:
板兰伯特光照(左) & 普通光照(右)最后给出三个 Shader 的对比图:
Half Lambert(左)逐像素(中)逐顶点(右)
网友评论