第6章 Unity 中的基础光照
6.1 我们是如何看到这个世界的
通常来说我们要模拟真实的光照环境来生成一张图像,需要考虑3种物理现象:
- 首先,光线从
光源(light source)
中被发射出来 - 然后,光线和场景中的一些物体相交,一些光线被吸收,一些被散射到其他方向
- 最后,摄像机吸收一些光,产生了一张图片
光源
在光学中,我们使用辐照度(irradiance)
来量化光,对于平行光,它的辐射度可通过计算在垂直于光线方向的单位面积上的单位时间内穿过的能量来得到。
因为辐照度和照射到物体表面时光线之间的距离 d/cos 成反比,所以其与 cos 成正比,cos 可以使用光源方向和表面法线 n 的点积来得到。
吸收和散射
光线由光源发射出去后,就会和一些物体相交,相交的结果有两个:散射(scattering)
和吸收(absorption)
。散射只改变光线的方向,不改变光线的密度和颜色;而吸收只改变光线的密度和颜色,但不改变光线的方向。
光线在物体表面经过散射后,有两种方向:一种将会散射到物体内部,这种现象称为折射(refraction)
或透射(transmission)
;另一种将会散射到外部,这种现象被称为反射(reflection)
。对于不透明物体,折射进入物体内部的光线还会继续与内部的颗粒相交,其中一些光线最后会重新发射出物体表面,而另一些则被物体吸收。那些从物体表面重新发射出的光线将具有和入射光线不同的方向分布和颜色。
为了区分这两种不同的散射方向,我们在光照模型中使用了不同的部分来计算它们:高光反射(specular)
部分表示物体表面是如何反射光线的,而漫反射(diffuse)
部分则表示有多少光线会被折射、吸收和散射出表面。根据入射光线的数量和方向,我们通常使用出射度(exitance)
来描述它。辐照度和出射度之间是满足线性关系的,而它们之间的比值就是材质的漫反射和高光反射属性。
着色
着色(shading)
是指根据材质属性、光源信息,使用一个等式去计算沿某个观察方向的出射度的过程,我们通常也把这个等式称为光照模型(Lighting Model)
。
BRDF
BRDF(Bidirectional Reflectance Distribution Function)指定义了光如何在不透明表面反射的四个实变量的函数。当给定入射光线的方向和辐照度后,BRDF 可以给出在某个出射方向上的光照能量分布。本章涉及的 BRDF 都是对真实场景进行理想化和简化后的模型,也就是说,它们并不能真实地反映物体和光线之间的交互,这些光照模型被称为是经验模型。尽管如此,这些经验模型仍然在实时渲染领域被应用了多年。有时我们希望可以更加真实地模拟光和物体的交互,这就出现了基于物理的 BRDF 模型
,我们会在后续看到这些更加复杂的光照模型。
6.2 标准光照模型
虽然光照模型有很多种类,但在早期的游戏引擎中往往只使用了一个光照模型,那就是标准光照模型。它的基本方法是把进入摄像机内的光线分为4个部分,每个部分使用一种方法来计算它的贡献度:
自发光(emissive)
,这个部分用于描述当给定一个方向时,一个表面本身会向该方向发射多少辐射度。如果没有使用全局光照(global illumination),这些自发光表面不会真的照亮周围的物体,而只是使它们自身看起来更亮了而已。高光反射(specular)
,这个部分用于描述当光线从光源照射到模型表面时,该表面在完全镜面反射方向散射多少辐射量。漫反射(diffuse)
,这个部分用来描述当光线从光源照射到模型表面时,该表面会向每个方向散射多少辐射量。环境光(ambient)
,这个部分用于描述其他所有的间接光照。
环境光
虽然标准光照模型的重点在于描述直接光照,但在真实世界中,物体也可以被间接光照
(indirect light)照亮。间接光照指的是,在多个物体间进行反射,最后进入摄像机的光线。
在标准光照模型中,我们使用了一种被称为环境光的部分来近似模拟间接光照。环境光的计算非常简单,它通常是一个全局变量,即场景中的所有物体都使用这个环境光:
自发光
光线也可以直接从光源发射并进入摄像机。标准光照模型使用自发光来计算这部分的贡献度,它的计算也很简单,就是直接使用了该材质的自发光颜色:
通常的实时渲染中,自发光的表面并不会照亮周围的表面,也就是说,这个物体并不会被当作一个光源。Unity 5引入的全局光照系统
则可以模拟这类自发光物体对周围物体的影响。
漫反射
漫反射光照是用于对那些被物体表面随机散射到各个方向的辐射度进行建模的,在漫反射中,视角的位置是不重要的,因为反射是完全随机的,因此可以认为在任何反射方向上的分布都是一样的。
但是,入射光线的角度很重要,漫反射光照符合兰伯特定律(Lambert's law)
: 反射光线的强度与表面法线和光源方向之间夹角的余弦值成正比。
其中,n^ 是表面法线,l^ 是指向光源的单位矢量,Mdiffuse 是材质的漫反射颜色,Clight是光源颜色。需要注意的是,我们需要防止法线和光源方向点乘的结果为负值,为此,我们使用取最大值的函数来将其截取到0,这可以防止物体被从后面来的光源照亮。
高光反射
这里的高光反射是一种经验模型,也就是说,它并不完全符合真实世界中的高光反射现象。计算高光反射需要知道的信息比较多:表面法线 n、视角方向 v、光源方向 l、反射方向 r。
在这四个矢量中,我们实际上只需要知道其中3个矢量即可,而第四个矢量——反射方向可以通过其他信息计算得到:
这样,我们就可以利用Phong 模型
来计算高光反射的部分:
其中,Mgloss是材质的光泽度(gloss)
,也被称为反光度(shininess)。它用于控制高光区域的“亮点”有多宽,Mgloss 越大,亮点就越小。Mspecular 是材质的高光反射颜色,它用于控制该材质对于高光反射的强度和颜色。Clight 则是光源的颜色和强度。同样,这里也需要防止 v * r 的结果为负数。
和上述的 Phong 模型相比,Blinn 提出了一个简单的修改方法来得到类似的效果。它的基本思想是,避免计算反射方向。为此,Blinn 模型
引入了一个新的矢量 h,它是通过对 v 和 l 取平均后再归一化得到的。
然后,使用 n 和 h 之间的夹角进行计算,而非 v 和 r 之间的夹角:
在硬件实现时,如果摄像机和光源距离模型足够远的话,Blinn 模型会快于 Phong 模型,这是因为,此时可以认为 v 和 l 都是定值,因此 h 将是一个常量。但是,当 v 或者 l 不是定值时,Phong 模型可能反而更快一些。
需要这两种光照模型都是经验模型,它们并不完全完全符合真实世界中的高光反射现象。
逐顶点还是逐像素
我们通常在实现上述的光照模型时有两种选择:
- 在片元着色器中计算,也被称为
逐像素光照(per-pixel lighting)
。在逐像素光照中,我们会以每个像素为基础,得到它的法线,然后进行光照模型的计算。这种在面片之间对顶点法线进行插值的技术被称为Phong 着色(Phong shading)
,也被称为Phong插值或法线插值着色技术。 - 在顶点着色器中计算,被称为
逐顶点光照(per-vertex lighting)
,也被称为高洛德着色(Gouraud shading)
。在逐顶点光照中,我们在每个顶点上计算光照,然后会在渲染图元内部进行线性插值,最后输出成像素颜色。 - 由于顶点数目往往远小于像素数目,因此逐顶点光照的计算量往往要小于逐像素光照。但是,由于逐顶点光照依赖于线性插值来得到像素光照,因此,当光照模型中有非线性的计算(例如计算高光反射时)时,逐顶点光照就会出问题。
6.3 Unity 中的环境光与自发光
Unity 场景中环境光可以在 window -> Rending -> Ligthing
中设置
具体光线设置可直接参考 Unity 官方手册
另外要在 Unity 中实现自发光也非常简单,我们只需要在片元着色器输出最后的颜色之前,把材质的自发光颜色添加到输出颜色上即可。
6.4 在 Unity Shader 中实现漫反射光照模型
逐顶点光照
Shader "Chapter 6/Diffuse Vertex Level"
{
Properties
{
// 定义漫反射颜色,默认为白色
_diffuse ("Diffuse", Color) = (1, 1, 1, 1)
}
SubShader
{
Pass
{
// 只有设置正确的光照模式,才能得到 Unity 的内置光照变量,比如 _LightColor0
Tags { "LightMode"="ForwardBase" }
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
// 为了使用 Unity 内置的一些变量(比如 _LightColor0),必须包含相应 Unity 内置文件
#include "UnityLightingCommon.cginc"
fixed4 _diffuse;
struct a2v
{
float4 vertex : POSITION;
float3 normal : NORMAL;
};
struct v2f
{
float4 vertex : SV_POSITION;
fixed3 color : COLOR;
};
v2f vert (a2v v)
{
v2f o;
// 坐标转换
o.vertex = UnityObjectToClipPos(v.vertex);
// 获取环境光
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
// 转换法线向量至世界坐标系
fixed3 worldNormal = normalize(mul(v.normal, (float3x3)unity_WorldToObject));
// 场景中只有一个光源且是平行光时,光源方向可以由 _WorldSpaceLightPos0 得到,当有多个光源时无法这样使用
fixed3 worldLight = normalize(_WorldSpaceLightPos0.xyz);
// saturate 函数用于将结果截取至 [0,1]
fixed3 diffuse = _LightColor0.rgb * _diffuse.rgb * saturate(dot(worldNormal, worldLight));
// 将漫反射颜色与环境光叠加,作为返回结果
o.color = ambient + diffuse;
return o;
}
fixed4 frag (v2f i) : SV_Target
{
return fixed4(i.color, 1.0);
}
ENDCG
}
}
Fallback "Diffuse"
}
逐像素光照
Shader "Chapter 6/Diffuse Frag Level"
{
Properties
{
// 定义漫反射颜色,默认为白色
_diffuse ("Diffuse", Color) = (1, 1, 1, 1)
}
SubShader
{
Pass
{
// 只有设置正确的光照模式,才能得到 Unity 的内置光照变量,比如 _LightColor0
Tags { "LightMode"="ForwardBase" }
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
// 为了使用 Unity 内置的一些变量(比如 _LightColor0),必须包含相应 Unity 内置文件
#include "UnityLightingCommon.cginc"
fixed4 _diffuse;
struct a2v
{
float4 vertex : POSITION;
float3 normal : NORMAL;
};
struct v2f
{
float4 vertex : SV_POSITION;
float3 worldNormal : TEXCOORD0;
};
v2f vert (a2v v)
{
// 顶点着色器只负责进行坐标转换
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.worldNormal = normalize(mul(v.normal, (float3x3)unity_WorldToObject));
return o;
}
fixed4 frag (v2f i) : SV_Target
{
// 颜色计算移动至片元着色器中进行
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
fixed3 worldLight = normalize(_WorldSpaceLightPos0.xyz);
fixed3 diffuse = _LightColor0.rgb * _diffuse.rgb * saturate(dot(i.worldNormal, worldLight));
fixed3 color = ambient + diffuse;
return fixed4(color, 1.0);
}
ENDCG
}
}
Fallback "Diffuse"
}
半兰伯特模型
无论是使用逐顶点还是逐像素,都会有一个问题:在光照无法到达的区域,模型的外观通常是全黑的,没有任何明暗变化,这会使模型的背光区域看起来就像一个平面一样,失去了模型细节表现。为此,有一种改善技术被提出来,这就是半兰伯特(Half Lambert)
光照模型。
可以看出,与原兰伯特模型相比,半兰伯特光照模型没有使用 max 操作来防止和的点积为负值,而是对其结果进行了一个倍的缩放再加上一个大小的偏移。绝大多数情况下,和的值均为0.5,即公式为:
通过这样的方式,我们可以把 n*l 的结果范围从[−1, 1]映射到[0, 1]范围内。也就是说,对于模型的背光面,在原兰伯特光照模型中点积结果将映射到同一个值,即0值处;而在半兰伯特模型中,背光面也可以有明暗变化,不同的点积结果会映射到不同的值上。需要注意的是,半兰伯特是没有任何物理依据的,它仅仅是一个视觉加强技术。
下面是半兰伯特模型的逐顶点实现:
// Upgrade NOTE: replaced '_World2Object' with 'unity_WorldToObject'
Shader "Chapter 6/Diffuse Half Lambert"
{
Properties
{
_diffuse ("Diffuse", Color) = (1, 1, 1, 1)
}
SubShader
{
Pass
{
Tags { "LightMode"="ForwardBase" }
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
#include "UnityLightingCommon.cginc"
fixed4 _diffuse;
struct a2v
{
float4 vertex : POSITION;
float3 normal : NORMAL;
};
struct v2f
{
float4 vertex : SV_POSITION;
fixed3 color : COLOR;
};
v2f vert (a2v v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
fixed3 worldNormal = normalize(mul(v.normal, (float3x3)unity_WorldToObject));
fixed3 worldLight = normalize(_WorldSpaceLightPos0.xyz);
// 半兰伯特模型算法
fixed3 halfLambert = dot(worldNormal, worldLight) * 0.5 + 0.5;
fixed3 diffuse = _LightColor0.rgb * _diffuse.rgb * halfLambert;
o.color = ambient + diffuse;
return o;
}
fixed4 frag (v2f i) : SV_Target
{
return fixed4(i.color, 1.0);
}
ENDCG
}
}
Fallback "Diffuse"
}
下面是逐顶点漫反射光照、逐像素漫反射光照和半兰伯特光照的对比效果:
6.5 Unity Shader 实现高光反射光照模型
逐顶点光照
Shader "Chapter 6/Specular Vertex Level"
{
Properties
{
// 定义漫反射颜色,默认为白色
_Diffuse ("Diffuse", Color) = (1, 1, 1, 1)
// 高光反射颜色
_Specular ("Specular", Color) = (1, 1, 1, 1)
// 用于控制高光区域大小
_Gloss ("Gloss", Range(8.0, 256)) = 20
}
SubShader
{
Pass
{
// 只有设置正确的光照模式,才能得到 Unity 的内置光照变量,比如 _LightColor0
Tags { "LightMode"="ForwardBase" }
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
// 为了使用 Unity 内置的一些变量(比如 _LightColor0),必须包含相应 Unity 内置文件
#include "UnityLightingCommon.cginc"
fixed4 _Diffuse;
fixed4 _Specular;
float _Gloss;
struct a2v
{
float4 vertex : POSITION;
float3 normal : NORMAL;
};
struct v2f
{
float4 vertex : SV_POSITION;
fixed3 color : COLOR;
};
v2f vert (a2v v)
{
v2f o;
// 坐标转换
o.vertex = UnityObjectToClipPos(v.vertex);
// 环境光
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
/*漫反射*/
fixed3 worldNormal = normalize(mul(v.normal, (float3x3)unity_WorldToObject));
fixed3 worldLight = normalize(_WorldSpaceLightPos0.xyz);
fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(worldNormal, worldLight));
/*高光反射*/
// 函数 reflect 可直接根据入射光角度与法线向量计算出反射方向
fixed3 reflectDir = normalize(reflect(-worldLight, worldNormal));
fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - mul(unity_ObjectToWorld, v.vertex).xyz);
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(saturate(dot(reflectDir, viewDir)), _Gloss);
// 叠加环境光、漫反射、高光反射,作为返回结果
o.color = ambient + diffuse + specular;
return o;
}
fixed4 frag (v2f i) : SV_Target
{
return fixed4(i.color, 1.0);
}
ENDCG
}
}
Fallback "Specular"
}
逐像素光照
Shader "Chapter 6/Specular Frag Level"
{
Properties
{
// 定义漫反射颜色,默认为白色
_Diffuse ("Diffuse", Color) = (1, 1, 1, 1)
// 高光反射颜色
_Specular ("Specular", Color) = (1, 1, 1, 1)
// 用于控制高光区域大小
_Gloss ("Gloss", Range(8.0, 256)) = 20
}
SubShader
{
Pass
{
// 只有设置正确的光照模式,才能得到 Unity 的内置光照变量,比如 _LightColor0
Tags { "LightMode"="ForwardBase" }
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
// 为了使用 Unity 内置的一些变量(比如 _LightColor0),必须包含相应 Unity 内置文件
#include "UnityLightingCommon.cginc"
fixed4 _Diffuse;
fixed4 _Specular;
float _Gloss;
struct a2v
{
float4 vertex : POSITION;
float3 normal : NORMAL;
};
struct v2f
{
float4 vertex : SV_POSITION;
fixed3 worldNormal : TEXCOORD0;
fixed3 worldPos : TEXCOORD1;
};
v2f vert (a2v 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 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
/*漫反射*/
fixed3 worldLight = normalize(_WorldSpaceLightPos0.xyz);
fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(i.worldNormal, worldLight));
/*高光反射*/
// 函数 reflect 可直接根据入射光角度与法线向量计算出反射方向
fixed3 reflectDir = normalize(reflect(-worldLight, i.worldNormal));
fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(saturate(dot(reflectDir, viewDir)), _Gloss);
// 叠加环境光、漫反射、高光反射,作为返回结果
fixed3 color = ambient + diffuse + specular;
return fixed4(color, 1.0);
}
ENDCG
}
}
Fallback "Specular"
}
Blinn-Phong光照模型的逐像素实现
Shader "Chapter 6/Specular Blinn-Phong"
{
Properties
{
// 定义漫反射颜色,默认为白色
_Diffuse ("Diffuse", Color) = (1, 1, 1, 1)
// 高光反射颜色
_Specular ("Specular", Color) = (1, 1, 1, 1)
// 用于控制高光区域大小
_Gloss ("Gloss", Range(8.0, 256)) = 20
}
SubShader
{
Pass
{
// 只有设置正确的光照模式,才能得到 Unity 的内置光照变量,比如 _LightColor0
Tags { "LightMode"="ForwardBase" }
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
// 为了使用 Unity 内置的一些变量(比如 _LightColor0),必须包含相应 Unity 内置文件
#include "UnityLightingCommon.cginc"
fixed4 _Diffuse;
fixed4 _Specular;
float _Gloss;
struct a2v
{
float4 vertex : POSITION;
float3 normal : NORMAL;
};
struct v2f
{
float4 vertex : SV_POSITION;
fixed3 worldNormal : TEXCOORD0;
fixed3 worldPos : TEXCOORD1;
};
v2f vert (a2v 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 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
/*漫反射*/
fixed3 worldLight = normalize(_WorldSpaceLightPos0.xyz);
fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(i.worldNormal, worldLight));
/*高光反射*/
// 函数 reflect 可直接根据入射光角度与法线向量计算出反射方向
fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
// Blinn-Phong 模型算法
fixed3 halfDir = normalize(worldLight + viewDir);
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(halfDir, i.worldNormal)), _Gloss);
// 叠加环境光、漫反射、高光反射,作为返回结果
fixed3 color = ambient + diffuse + specular;
return fixed4(color, 1.0);
}
ENDCG
}
}
Fallback "Specular"
}
下面是以上三种实现(从左到右)的对比效果图:
- 按逐像素的方式处理光照可以得到比逐顶点更加平滑的高光效果
- Blinn-Phong光照模型的高光反射部分看起来更大、更亮一些。在实际渲染中,绝大多数情况我们都会选择Blinn-Phong光照模型。
6.6 使用 Unity 内置函数
函数详见 UnityCG.cginc
中源码,此处省略。