Unity 渲染原理(十六)Unity立方体纹理

我们之前用到的纹理,比如法线纹理,渐变纹理,遮罩纹理等,都是一维或者二维的,这次我们来介绍一下立方体纹理,并看一下如何使用立方体纹理实现环境映射。

在图形学中,立方体纹理(Cubemap)是环境映射(Environment Mapping)的一种实现方案。环境映射可以模拟物体周围的环境,而使用了环境映射的物体可以看起来像镀了一层金属一样反射出周围的环境。

立方体纹理一共包含六张图像,这些图像对应了一个立方体的6个面,立方体纹理的名称也由此而来。立方体的每个面表示沿着世界空间下的轴向(上,下,左,右,前,后)观察所得到的图像。

那么我们如何对这种纹理进行采样呢?和之前使用的二维纹理坐标不同,对立方体纹理采样我们需要提供一个三维的纹理坐标,这个三维纹理坐标表示了我们在世界空间下的一个3D方向。这个矢量从立方体的中心发出,当它向外部延伸时,就会和立方体的6个纹理之一发生相交,而采样结果就是由该交点计算而来的

立方体纹理的好处在于,实现简单快捷,而且效果也比较好。但是也有一些缺点,例如当场景中引入新的物体,光源或者物体发生移动时,我们需要重新生成立方体纹理。除此之外,立方体纹理也仅可以反射环境,但不能反射使用了该立方体纹理的物体本身。这是因为,立方体纹理不能摸你多次反射的结果,例如两个金属球互相反射的情况。

立方体纹理在实时渲染中有很多应用,最常见的是天空盒。

天空盒

天空盒是游戏中用于模拟背景的一种方法。天空盒子这个名字也包含了两个信息:它是用来模拟天空的,其次它是一个盒子。当我们在场景中使用了天空盒子,我们的整个场景就被包围在一个立方体内。这个立方体的每个面使用的技术就是立方体纹理映射技术

天空盒的使用比较简单,就不赘述,主要是六张贴图的WrapMode选择Clamp,防止因为精度问题出现接缝处不匹配的情况

如何创建一个立方体纹理

在Unity5中,创建用于环境映射的立方体纹理方法有三种:

  • 直接由一些特殊布局的纹理创建。这种纹理可以大概想象成这个贴图本身就是立方体展开图的布局方式。
  • 手动创建一个Cubemap资源,再把六张图赋给它
  • 第三种方法是脚本生成

Unity官方推荐采用第一种,这种方法可以对纹理数据进行压缩,而且支持边缘修正,光滑反射(glossy reflection)和HDR等

前面两种方法都需要我们提前准备好立方体纹理的图像,我们得到的立方体纹理往往是被场景中的物体所共用的。在理想情况下,我们希望根据物体在场景中位置不同,生成各自不同的立方体纹理。这是,我们可以在Unity中使用脚本来创建。这是通过利用Unity提供的Camera.RenderToCubemap函数来实现的。该函数可以把从任意位置看到的场景图像存储到六张图像中,从而创建出该位置上对应的立方体纹理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
using UnityEditor;
using UnityEngine;

namespace Tools.Scripts.CreateCubemap
{
public class CreateCubemap: ScriptableWizard
{
public Cubemap cubemap;
public Transform renderFromPosition;
[MenuItem("Tools/CreateCubemap")]
public static void Create()
{
ScriptableWizard.DisplayWizard<CreateCubemap>("CreateCubemap", "Create");
}

void OnWizardCreate() {
GameObject go = new GameObject("CubemapCamera");
go.AddComponent<Camera>();
go.transform.position = renderFromPosition.position;
go.GetComponent<Camera>().RenderToCubemap(cubemap);
DestroyImmediate(go);
}
}
}

在上述代码中,我们在renderFromPosition的位置创建一个摄像机,并调用RenderToCubemap函数把当前观察到的图像渲染到用户指定的立方体纹理中,完成后再销毁。

使用起来也很简单:

  • 该代码会在编辑器菜单中添加一个菜单项,我们点击"Tools->CreateCubemap"可以唤出面板
  • 然后在Project中右键"Create -> Legacy -> Cubemap"创建一个Cubemap,并且在面板中勾选Readable,然后把它赋给面板上的cubemap变量
  • 在场景中创建一个gameobject,然后把它赋给面板的renderFromPosition变量
  • 点击Create按钮

然后我们就可以得到从该gameobject位置看到的环境纹理了。

创建好以后,我们就可以使用环境映射技术了。最常见的环境映射技术就是反射和折射了。

反射

使用了反射效果的物体通常看起来就像是镀了层金属。想要模拟反射效果很简单,只需要通过入射光线的方向和表面法显方向来计算反射方向,再利用反射方向对立方体纹理采样即可。

我们需要做以下准备:

  • 创建一个新场景,然后用刚才我们生成的Cubemap替换新场景的天空盒
  • 像场景中拖入一个Teapot,位置与上一步我们用于生成Cubemap的gameobject一样
  • 新建一个材质,命名为ReflectionMat,然后把材质赋给Teapot模型
  • 新建一个Shader,命名为Reflection然后赋给上一步创建的材质

反射的Shader比较简单,大概分为以下几步:

(1)声明新的属性:

1
2
3
4
5
6
Properties {
_Color("Color Tint", Color) = (1,1,1,1)
_ReflectColor("Reflection Color", Color) = (1,1,1,1)
_ReflectAmount("Reflect Amount", Range(0,1)) = 1
_Cubemap("Reflection Cubemap", Cube) = "_Skybox"{}
}

(2) 在顶点着色器中计算该点处的反射方向,我们直接使用CG的reflect函数来计算

1
2
3
4
5
6
7
8
9
10
v2f vert(a2v v) {
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_Object2World, v.vertex).xyz;
o.worldViewDir = UnityWorldSpaceViewDir(o.worldPos);
o.worldRefl = reflect(-o.worldViewDir, o.worldNormal);
TRANSFER_SHADOW(o);
return o;
}

(3) 在片元着色器中利用反射方向来采样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fixed4 frag(v2f i): SV_Target {
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
fixed3 worldViewDir = normalize(i.worldViewDir);

fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;

fixed3 diffuse = _LightColor0.rgb * _Color.rgb * max(0, dot(worldNormal, worldLightDir));

fixed3 reflection = texCUBE(_Cubemap, i.worldRefl).rgb * _ReflecColor.rgb;

UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);

fixed3 color = ambient + lerp(diffuse, reflection, _ReflectAmount) * atten;

return fixed4(color, 1.0);
}

折射

这一节,我们将学习如何在Unity Shader中模拟另一个环境映射常见的应用————折射。

折射的物理原理比反射复杂一些。我们在初中物理就已经接触过折射的定义了;当光线从一种介质斜射入另一种介质时,传播方向一般会发生变化。当给定入射角时,我们可以使用斯涅尔定理来计算反射角。当光从介质1沿着和表面法线夹角为θ1θ_1的方向射入介质2,可以使用如下公式计算折射光线与法线的夹角θ2θ_2

n1sinθ1=n2sinθ2n_1sinθ_1 = n_2sinθ_2

n1n_1n2n_2分别是两个介质的折射率,折射率是一项重要的物理常数。

通常来讲,得到折射方向后我们就会直接使用它来对立方体纹理进行采样,但这是不符合物理规律的。对一个透明物体来说,一种更准确的模拟方法需要计算两次折射————一次是当光线进入它的内部时,而另一次则是从它内部射出时。但是,想要在实时渲染中模拟出第二次折射方向时比较复杂的,而仅仅模拟一次得到的效果看起来也问题不大,所以我们通常只模拟第一次折射。

我们首先需要做和上一节反射相似的准备工作,这次的Shader我们叫做Refraction

(1)声明新的属性:

1
2
3
4
5
6
7
Properties {
_Color("Color Tint", Color) = (1,1,1,1)
_RefractColor("Refraction Color", Color) = (1,1,1,1)
_ReflractAmount("Refraction Amount", Range(0,1)) = 1
_ReflractRatio("Refraction Ratio", Range(0,1)) = 1
_Cubemap("Reflection Cubemap", Cube) = "_Skybox"{}
}

(2) 在顶点着色器中计算该点处的折射方向,我们使用了refract函数来计算折射方向,它的第一个参数即为入射光线的方向,它必须是归一化后的矢量;第二个参数是表面法线,法线方向同样需要时归一化后的,第三个参数是入射光线所在介质的折射率和折射光线所在的戒指的折射率之间的比值

1
2
3
4
5
6
7
8
9
10
v2f vert(a2v v) {
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_Object2World, v.vertex).xyz;
o.worldViewDir = UnityWorldSpaceViewDir(o.worldPos);
o.worldRefr = refract(-normalize(o.worldViewDir), normalize(o.worldNormal), _ReflractRatio);
TRANSFER_SHADOW(o);
return o;
}

(3) 在片元着色器中利用折射方向来采样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fixed4 frag(v2f i): SV_Target {
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
fixed3 worldViewDir = normalize(i.worldViewDir);

fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;

fixed3 diffuse = _LightColor0.rgb * _Color.rgb * max(0, dot(worldNormal, worldLightDir));

fixed3 refraction = texCUBE(_Cubemap, i.worldRefr).rgb * _RefractColor.rgb;

UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);

fixed3 color = ambient + lerp(diffuse, refraction, _RefractAmount) * atten;

return fixed4(color, 1.0);
}

菲涅尔反射

在实时渲染中,我们经常会使用菲涅尔反射来根据视角方向控制反射程度。通常而讲,菲涅尔反射描述了一种光学现象,即当光线照射到物体表面上时,一部分发生反射,一部分进入物体内部,发生折射或者散射。

被反射的光和入射光之间存在一定的比率关系,这个比率关系可以通过菲涅尔等式进行计算。一个经常使用的例子是,站在湖边,直接低头看脚边的水面,你会发现水几乎是透明的,你可以直接看到水底的小鱼和石子;但是当你抬头看到远处的水面时,会发现几乎看不到水下的情景,而只能看到水面反射的环境。这就是所谓的菲涅尔效果。

那么如何计算菲涅尔反射呢?需要用到菲涅尔等式。真实世界的菲涅尔等式是非常复杂的,但在实时渲染中我们通常会使用一些近似公式,比如Schlick菲涅尔近似等式

Fschlick(v,n)=F0+(1F0)(1vn)5F_{schlick}(v, n) = F_0 + (1 - F_0)(1 - v * n)^5

其中,F0F_0是一个反射系数,用于控制菲涅尔反射的强度,v是视角方向,n是表面法线方向。

另一个应用比较广泛的等式是Empricial菲涅尔近似等式

FEmpricial(v,n)=max(0,min(1,bias+scale(1vn)power))F_{Empricial}(v,n) = max(0, min(1, bias + scale * (1 - v * n)^{power}))

其中,bias,scale和power都是控制项。

我们尝试一个Schlick菲涅尔近似等式的Shader

(1)声明新的属性:

1
2
3
4
5
Properties {
_Color("Color Tint", Color) = (1,1,1,1)
_FrenelScale("Fresnel Scale", Range(0,1)) = 0.5
_Cubemap("Reflection Cubemap", Cube) = "_Skybox"{}
}

(2) 在顶点着色器中计算该点处的反射方向,法线方向,视角方向,我们直接使用CG的reflect函数来计算

1
2
3
4
5
6
7
8
9
10
v2f vert(a2v v) {
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_Object2World, v.vertex).xyz;
o.worldViewDir = UnityWorldSpaceViewDir(o.worldPos);
o.worldRefl = reflect(-o.worldViewDir, o.worldNormal);
TRANSFER_SHADOW(o);
return o;
}

(3) 在片元着色器中实现菲涅尔反射,并使用结果值混合漫反射光照和反射光照

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
fixed4 frag(v2f i): SV_Target {
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
fixed3 worldViewDir = normalize(i.worldViewDir);

fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;

UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);

fixed3 reflection = texCUBE(_Cubemap, i.worldRefl).rgb;

fixed3 fresnel = _FrenelScale + (1 - _FrenelScale) * pow(1 - dot(worldViewDir, worldNormal), 5);

fixed3 diffuse = _LightColor0.rgb * _Color.rgb * max(0, dot(worldNormal, worldLightDir));


fixed3 color = ambient + lerp(diffuse, reflection, saturate(fresnel)) * atten;

return fixed4(color, 1.0);
}