0.本文示例代码地址
1. 标准光照模型
标准光照模型只关心直接光照,也就是那些直接从光源发射出来,照射到物体表面后,一次反射进入摄像机的光线。光照模型考虑4个部分的光线
-
自发光(emissive)
物体本身会往给定方向发射的光线,物体的自发光并不会对周围其它的物体产生照射,具有自发光的物体并不是一个光源 -
漫反射(diffuse)
光线从光源照射到物体时,物体向不同方向散射的光线。漫反射与观察点位置和角度无关 -
高光反射(specular)
光源从光源照射到物体时,物体在完全镜面反射方向发射的光线。 -
环境光(ambient)
对环境中所有间接光照效果的简单模拟。
2. 逐顶点计算还是逐像素计算
2.1 逐顶点计算 per-vertext lighting
上述的四个部分计算放在顶点着色器中,针对每个顶点计算光照后的颜色,然后在图元所覆盖的像素进行线性插值,得到每个像素的颜色
2.2 逐像素计算 per-pixel lighting
上述四个部分计算放在像素着色器中,每个像素的位置、法线等信息由定点进行线性插值得到。
2.3 各自的优缺点
- 计算量:通常顶点数量远远小于像素数量,所以顶点光照的计算量比像素光照的计算量少
- 效果:当然是像素光照的效果更好,并且逐顶点光照不能处理有非线性计算的光照模型,因为非线性光照模型通过线性插值获得的结果不正确
这篇文章后面所涉及到的都是逐像素计算 shader,逐顶点的原理一样,只是计算放到了顶点着色函数中。
3. 漫反射部分的计算
3.1 兰伯特公式
在计算漫反射,只要物体和光源的位置和角度固定了,那么无论观察点的位置和角度怎么变化,物体表面某一点的漫反射颜色固定不变,也就是漫反射的结果与观察点无关。漫反射光照符合兰伯特定律(Lambert Law),在物体表面的任何一点,反射光线的强度与该点的表面法线和光源方向夹角的余弦值成正比。兰伯特公示如下:
其中,n是表面法线,I是指向光源的单位向量。Clight是光源颜色, Mdiffuse 是表面本身的颜色.
3.1.1 法线变换
我们对顶点进行坐标变换时,如果在后续的处理阶段中需要用到顶点法线信息,那么需要对顶点的法线也一并做变换,使用针对顶点的变换矩阵对法线进行变换时,有一种情况下得到的结果不正确:
当变换包括一个非等比缩放时,用同样的变换矩阵对法线变换,变换后的法线和平面不垂直。
解决方法(如何推导的先不用管):使用顶点变换矩阵的逆转置矩阵 来对法线进行变换。在兰伯特公式中,我们需要确保光源方向和法线方向在同一个坐标系才有意义。我们计算时采用世界坐标系,而顶点着色器中的顶点数据是模型坐标系的,因此我们需要将法线变换成世界坐标系。如何将顶点的法线(appdata传递过来的 NORMAL 是模型坐标系)转换到世界坐标系呢?
首先我们看看 Unity shader 中如何将顶点从模型坐标转换到世界坐标:使顶点右乘 一个内置的变换矩阵 unity_ObjectToWorld:
o.pos = mul(unity_ObjectToWorld, v.vertex);
那么如何进行法线变换呢?Unity 内置的 unity_WorldToObject 是 unity_ObjectToWorld 的逆矩阵,而左乘则相当于使用转置矩阵进行了右乘,所以法线变换的过程如下:
float4 worldNormal = normalize(mul(v.normal, (float3x3)unity_WorldToObject));
3.1.2 兰伯特公式的顶点着色实现
我们新建一个 Lambert.shader,在片元着色器中计算漫反射(也可以在顶点着色器中计算,将对应代码移动到顶点着色器中就可以了),代码如下:
Shader "Shader_Examples/03_Lambert"
{
Properties
{
_MainColor ("MainColor", Color) = (1, 1, 1, 1)
}
SubShader
{
Tags { "LightMode"="ForwardBase" }
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
#include "Lighting.cginc"
struct appdata
{
float4 vertex : POSITION;
float3 normal : NORMAL;
};
struct v2f
{
float4 pos : SV_POSITION;
float3 worldNormal : TEXCOORD0;
};
fixed4 _MainColor;
v2f vert (appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex); // 顶点变换到裁剪空间
o.worldNormal = normalize(mul(v.normal, (float3x3)unity_WorldToObject)); // 法线变换到世界空间
return o;
}
fixed4 frag (v2f i) : SV_Target
{
fixed3 worldLight = normalize(_WorldSpaceLightPos0.xyz); // 光源方向归一化
fixed diffuse = saturate(dot(i.worldNormal, worldLight)); // 计算漫反射强度
fixed3 color = _LightColor0.rgb * _MainColor.rgb * diffuse;
return fixed4(color, 1.0);
}
ENDCG
}
}
}
SubShader 一定要指定Tag : "LightMode" = "ForwardBase",否则使用内置变量 _WorldSpaceLightPos0 将不正确。
渲染的结果:
3.2 半兰伯特公式
上面的 Lambert.shader计算光照时,法线和光源方向点乘时有可能得到负数,兰伯特光照模型使用 saturate 来确保最终得到的数为正数
saturate(x) = max(0, x);
将不会受到光照射的点(该点法线和光线夹角余弦为负数)显示为黑色。虽然是符合理解,但实际并不好看,例如上图中胶囊体和球体最左边的黑色部分。为了让画面更好看一些,提出了一种区间转化的取巧方法,将(-1,1)转化到(0,1),这个方法提升了画面整体亮度。相对于上面的 03_Lambert shader,半兰伯特光照模型只是在计算漫反射部分有细微的区别:
fixed diffuse = dot(i.worldNormal, worldLight) * 0.5 + 0.5; // 计算漫反射强度
可以看一下兰伯特(上排)和半兰伯特(下排)的渲染结果对比
4. 高光部分的计算
4.1 Phong光照模型
高光反射用于计算那些完全镜面反射方向的光线,可以是物体看起来更接近镜面的光滑。计算高光反射,需要知道法线n、光源方向l、视角方向v和反射方向r。
从不同的观察点看同一个点,高光结果肯定是不同的,所以高光与观察点也有关系,高光强度的计算公式为:
如何计算反射方向r?把n、l、v都看成是单位向量,从 l 的终点作一条平行于r的向量,必然与n相交于一点,可以轻易的得到 r 的计算公式:
在编写 shader 时,可以使用 Cg 提供的内置函数 reflect
fixed3 reflectDir = normalize(reflect(-worldLightDir, worldNormal));
注:公式中的I是光源方向,上述代码中的 reflect 的第一个参数是入射光方向,二者互为负向量。
gloss 是一个调节表面高光反射“集中度”的一个参数,shader写好之后可以调整这个值看直观效果。
到此,在上面的 Half_Lambert shader 的基础上,我们可以加入高光的计算。最终shader代码:
Shader "Shader_Examples/03_Phong"
{
Properties
{
_MainColor ("MainColor", Color) = (1,1,1,1)
_SpecularColor ("SpecularColor", Color) = (1,1,1,1)
_Gloss ("Gloss", Range(8.0, 256)) = 20
}
SubShader
{
Tags { "RenderType"="Opaque" "LightMode"="ForwardBase"}
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Lighting.cginc"
struct appdata
{
float4 vertex : POSITION;
float3 normal : NORMAL;
};
struct v2f
{
float4 vertex : SV_POSITION;
float3 worldNormal : TEXCOORD0;
float3 worldPos : TEXCOORD1;
};
float4 _MainColor;
float4 _SpecularColor;
float _Gloss;
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.worldNormal = normalize(mul(v.normal, (float3x3)unity_WorldToObject)); // 法线变换
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; // 顶点世界坐标
return o;
}
fixed4 frag (v2f i) : SV_Target
{
fixed3 worldNormal = i.worldNormal;
fixed3 lightInDir = -normalize(_WorldSpaceLightPos0.xyz); // 入射光线方向
fixed3 reflectDir = normalize(reflect(lightInDir, worldNormal)); // 反射光线方向
fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz); // 视线反方向(点到摄像机)
// 计算高光
fixed3 specular = _LightColor0.rgb * _SpecularColor.rgb * pow(saturate(dot(reflectDir, viewDir)), _Gloss);
// 半兰伯特计算漫反射
fixed3 diffuse = _LightColor0.rgb * _MainColor.rgb * (dot(-lightInDir, worldNormal) * 0.5 + 0.5);
fixed4 col = fixed4(specular + diffuse, 1.0);
return fixed4(col);
}
ENDCG
}
}
}
注1. 在进行法线变换时,因为法线是3维向量,需要将unity_WorldToObject转维3维矩阵
o.worldNormal = normalize(mul(v.normal, (float3x3)unity_WorldToObject));
注2. 一定要注意计算反射光线的函数 reflect 的第一个输入参数是入射方向而不是光线方向
渲染结果如下:
从上到下3排依次为:兰伯特漫反射、半拉伯特漫反射、半兰伯特+Phong高光,为了能比较明显的看到高光部分,我给灯光添加了一个颜色,高光颜色设置为白色。
4.2 Blinn-Phong
Blinn 在 Phong 光照模型上引入了“半程向量”的概念来进行模拟,不再需要计算反射方向,而使用光线方向l和视线方向v的“中间向量”来模拟。高光部分通过半程向量 h 和 表面法线 n 来确定。半程向量 h 通过对 l 和 v 平均后归一化得到。
引入 半程向量后,Blinn-Phong 的光照计算公式为:
将上述的 03_Phong 光照Shader中高光部分计算替换为如下的代码即可:
fixed4 frag (v2f i) : SV_Target
{
fixed3 worldNormal = i.worldNormal;
fixed3 lightDir = normalize(_WorldSpaceLightPos0.xyz); // 入射光线方向
fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz); // 视线反方向(点到摄像机)
fixed3 halfDir = normalize(lightDir + viewDir);
// 计算高光
fixed3 specular = _LightColor0.rgb * _SpecularColor.rgb * pow(saturate(dot(worldNormal, halfDir)), _Gloss);
// 半兰伯特计算漫反射
fixed3 diffuse = _LightColor0.rgb * _MainColor.rgb * (dot(lightDir, worldNormal) * 0.5 + 0.5);
fixed4 col = fixed4(specular + diffuse, 1.0);
return fixed4(col);
}
最终将得到如下的渲染结果:
5. 环境光
环境光比较简单,直接在每个像素上面添加一个固定的颜色就可以了。
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
fixed4 col = fixed4(specular + diffuse + ambient, 1.0);
在上面的 Blinn-Phong 代码中添加相应的环境光,并在 Unity 中设置一个偏黄的环境光,最后得到的渲染效果如下:
如何在 Unity 中设置环境光颜色?
Unity 菜单 -> Window -> Lighting -> Settings,得到如下对话框,进行修改即可。
6 自发光
物体自发光也比较简单,直接给材质增加一个应颜色属性,在最终计算颜色时将这个颜色属性加上即可。