高级纹理(一)

本文最后更新于:2025年4月17日 下午

立方体纹理

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

立方体纹理一共包含了 6 张图像,这些图像对应了一个立方体的 6 个面,立方体纹理的名称也由此而来。立方体的每个面表示沿着世界空间下的轴向(上、下、左、右、前、后)观察所得的图像。对立方体纹理采样需要提供一个三维的纹理坐标,这个三维纹理坐标表示了在世界空间下的 3D 方向。这个方向矢量从立方体的中心出发,当它向外部延伸时就会和立方体的 6 个纹理之一发生相交,而采样得到的结果就是由该交点计算而来的。下图给出了使用方向矢量对立方体纹理采样的过程。

对立方体纹理的采样

立方体纹理的好处在于实现简单快速,得到的效果也比较好,但是它也有一些缺点,例如当场景中引入了新的物体、光源,或者物体发生移动时,我们就需要重新生成立方体纹理。除此之外,立方体纹理也仅可以反射环境,但不能反射使用了该立方体纹理的物体本身。这是因为立方体纹理不能模拟多次反射的结果,例如两个金属球互相反射的情况(Unity 引入的全局光照系统允许实现这样的自反射效果)。由于这样的原因,想要得到令人信服的渲染结果,应该尽量对凸面体而不要对凹面体使用立方体纹理(因为凹面体会反射自身)。

立方体纹理在实时渲染中有很多应用,最常见的是用于天空盒子(Skybox)以及环境映射。

天空盒子

**天空盒子(Skybox)**是游戏中用于模拟背景的一种方法。当我们在场景中使用了天空盒子时,整个场景就被包围在一个立方体内。这个立方体的每个面使用的技术就是立方体纹理映射技术。

天空盒子材质

为了让天空盒子正常渲染,我们还需要把这 6 张纹理的 Wrap Mode 设置为 Clamp,以防在接缝处出现不匹配的现象。上面的材质中,除了 6 张纹理属性外还有 3 个属性:Tint Color,用于控制该材质的整体颜色;Exposure,用于调整天空盒子的亮度;Rotation,用于调整天空盒子沿 +y 轴方向的旋转角度。

为场景使用自定义的天空盒子

使用了天空盒子的场景

在 Unity 中,天空盒子是在所有不透明物体之后渲染的,而其背后使用的网格是一个立方体或一个细分后的球体。

创建用于环境映射的立方体纹理

除了天空盒子,立方体纹理最常见的用处是用于环境映射。通过这种方法,我们可以模拟出金属质感的材质。

在 Unity 中,创建用于环境映射的立方体纹理的方法有三种:第一种方法是直接由一些特殊布局的纹理创建;第二种方法是手动创建一个 Cubemap 资源,再把 6 张图赋给它;第三种方法是由脚本生成。

如果使用第一种方法,我们需要提供一张具有特殊布局的纹理,例如类似立方体展开图的交叉布局、全景布局等。然后,我们只需要把该纹理的 Texture Type 设置为 Cubemap 即可,Unity 会为我们做好剩下的事情。在基于物理的渲染中,我们通常会使用一张 HDR 图像来生成高质量的 Cubemap。

第二种方法是 Unity 5 之前的版本中使用的方法。我们首先需要在项目资源中创建一个 Cubemap,然后把 6 张纹理拖拽到它的面板中。官方推荐使用第一种方法创建立方体纹理,这是因为第一种方法可以对纹理数据进行压缩,而且可以支持边缘修正、光滑反射(glossy reflection)和 HDR 等功能。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
using UnityEngine;
using UnityEditor;
using System.Collections;

public class RenderCubemapWizard : ScriptableWizard {

public Transform renderFromPosition;
public Cubemap cubemap;

void OnWizardUpdate () {
helpString = "Select transform to render from and cubemap to render into";
isValid = (renderFromPosition != null) && (cubemap != null);
}

void OnWizardCreate () {
// create temporary camera for rendering
GameObject go = new GameObject( "CubemapCamera");
go.AddComponent<Camera>();
// place it on the object
go.transform.position = renderFromPosition.position;
// render into cubemap
go.GetComponent<Camera>().RenderToCubemap(cubemap);

// destroy temporary camera
DestroyImmediate( go );
}

[MenuItem("GameObject/Render into Cubemap")]
static void RenderCubemap () {
ScriptableWizard.DisplayWizard<RenderCubemapWizard>(
"Render cubemap", "Render!");
}
}

在上面的代码中,我们在 renderFromPosition(由用户指定)位置处动态创建一个摄像机,并调用 Camera.RenderToCubemap 函数把从当前位置观察到的图像渲染到用户指定的立方体纹理 cubemap 中,完成后再销毁临时摄像机。由于该代码需要添加菜单栏条目,因此我们需要把它放在 Editor 文件夹下才能正确执行。

使用脚本创建立方体纹理

使用脚本渲染立方体纹理

需要注意的是,我们需要为 Cubemap 设置大小,即上图中的 Face size 选项。Face size 值越大,渲染出来的立方体纹理分辨率越大,效果可能更好,但需要占用的内存也越大,这可以由面板最下方显示的内存大小得到。

准备好了需要的立方体纹理后,我们就可以对物体使用环境映射技术。而环境映射最常见的应用就是反射和折射。

反射

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
// Upgrade NOTE: replaced '_Object2World' with 'unity_ObjectToWorld'

Shader "Custom/Chapter10/Chapter10-Reflection"
{
Properties {
_Color ("Color Tint", Color) = (1, 1, 1, 1)
_ReflectColor ("Reflection Color", Color) = (1, 1, 1, 1)
_ReflectAmount ("Reflection Amount", Range(0, 1)) = 1
_Cubemap ("Reflection Cubemap", Cube) = "_Skybox" {}
}

SubShader {
Tags { "RenderType"="Opaque" "Queue"="Geometry" }

Pass {

Tags { "LightMode"="ForwardBase" }

CGPROGRAM
#pragma multi_compile_fwdbase
#pragma vertex vert
#pragma fragment frag

#include "Lighting.cginc"
#include "AutoLight.cginc"

fixed4 _Color;
fixed4 _ReflectColor;
float _ReflectAmount;
samplerCUBE _Cubemap;

struct a2v {
float4 vertex : POSITION;
float3 normal : NORMAL;
};

struct v2f {
float4 pos : SV_POSITION;
float3 worldPos : TEXCOORD0;
float3 worldNormal : TEXCOORD1;
float3 worldViewDir : TEXCOORD2;
float3 worldRefl : TEXCOORD3;
SHADOW_COORDS(4)
};


v2f vert(a2v v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
o.worldViewDir = UnityWorldSpaceViewDir(o.worldPos);
o.worldRefl = reflect(-o.worldViewDir, o.worldNormal);

TRANSFER_SHADOW(o);
return o;
}

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 * _ReflectColor.rgb;

UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);

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

return fixed4(color, 1.0);
}



ENDCG
}

}

FallBack "Reflective/VertexLit"
}

_ReflectColor 用于控制反射颜色,_ReflectAmount 用于控制这个材质的反射程度,而 _Cubemap 就是用于模拟反射的环境映射纹理。使用 CG 中的 reflect 函数来计算顶点处的反射方向。物体反射到摄像机中的光线方向,可以由光路可逆的原则来求得。也就是说,我们可以计算视角方向关于顶点法线的反射方向来求得入射光线的方向。

对立方体纹理的采样需要使用 CG 的 texCUBE 函数。注意到,在上面的计算中,我们在采样时并没有对 i.worldRefl 进行归一化操作。这是因为,用于采样的参数仅仅是作为方向变量传递给 texCUBE 函数的,因此我们没有必要进行一次归一化的操作。然后,我们使用 _ReflectAmount 来混合漫反射颜色和反射颜色,并和环境光照相加后返回。

在上面的计算中,我们选择在顶点着色器中计算反射方向。当然,我们也可以选择在片元着色器中计算,这样得到的效果更加细腻。但是,对于绝大多数人来说这种差别往往是可以忽略不计的,因此出于性能方面的考虑,我们选择在顶点着色器中计算反射方向。

反射效果

折射

当光线从一种介质(例如空气)斜射入另一种介质(例如玻璃)时,传播方向一般会发生改变。当给定入射角时,我们可以使用**斯涅尔定律(Snell’s Law)**来计算反射角。当光从介质 1 沿着和表面法线夹角为 $\theta _1$ 的方向斜射入介质 2 时,我们可以使用如下公式计算折射光线和法线的夹角 $\theta _2$ :

$$
\eta _1\sin\theta _1 = \eta _2\sin\theta _2
$$

其中,$\eta _1$ 和 $\eta _2$ 分别是两个介质的折射率(index of refraction)。折射率是一项重要的物理常数,例如真空的折射率是 1,而玻璃的折射率一般是 1.5。下图给出了这些变量之间的关系。

斯涅尔定律

通常来说,当得到折射方向后我们就会直接使用它来对立方体纹理进行采样,但是这是不符合物理规律的。对一个透明物体来说,一种更准确的模拟方法需要计算两次折射——一次是当光线进入它的内部时,而另一次则是从它内部射出时。但是,想要在实时渲染中模拟出第二次折射方向是比较复杂的,而且仅仅模拟一次得到的效果在视觉上看起来“也挺像那么回事的”。正如我们之前提到的——图形学第一准则“如果它看起来是对的,那么它就是对的”。因此,在实时渲染中我们通常仅模拟第一次折射。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
// Upgrade NOTE: replaced '_Object2World' with 'unity_ObjectToWorld'

Shader "Custom/Chapter10/Chapter10-Refraction"
{
Properties {
_Color ("Color Tint", Color) = (1, 1, 1, 1)
_RefractColor ("Refraction Color", Color) = (1, 1, 1, 1)
_RefractAmount ("Refraction Amount", Range(0, 1)) = 1
_RefractRatio ("Refraction Ratio", Range(0.1, 1)) = 0.5
_Cubemap ("Refraction Cubemap", Cube) = "_Skybox" {}
}

SubShader {
Tags { "RenderType" = "Opaque" "Queue" = "Geometry" }

Pass {
Tags { "LightMode"="ForwardBase" }

CGPROGRAM

#pragma multi_compile_fwdbase

#pragma vertex vert
#pragma fragment frag

#include "Lighting.cginc"
#include "AutoLight.cginc"

fixed4 _Color;
fixed4 _RefractColor;
float _RefractAmount;
fixed _RefractRatio;
samplerCUBE _Cubemap;

struct a2v {
float4 vertex : POSITION;
float3 normal : NORMAL;
};

struct v2f {
float4 pos : SV_POSITION;
float3 worldPos : TEXCOORD0;
fixed3 worldNormal : TEXCOORD1;
fixed3 worldViewDir : TEXCOORD2;
fixed3 worldRefr : TEXCOORD3;
SHADOW_COORDS(4)

};

v2f vert(a2v v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
o.worldViewDir = UnityWorldSpaceViewDir(o.worldPos);

o.worldRefr = refract(-normalize(o.worldViewDir), normalize(o.worldNormal), _RefractRatio);

TRANSFER_SHADOW(o);
return o;
}

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).xyz * _RefractColor.rgb;

UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);

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

return fixed4(color, 1.0);
}

ENDCG
}
}

Fallback "Reflective/VertexLit"
}

我们使用 _RefractRatio 得到不同介质的透射比来计算折射方向,其他属性和控制反射时使用的属性类似。我们使用了 CG 的 refract 函数来计算折射方向。它的第一个参数即为入射光线的方向,它必须是归一化后的矢量;第二个参数是表面法线,法线方向同样需要是归一化后的;第三个参数是入射光线所在介质的折射率和折射光线所在介质的折射率之间的比值,例如如果是光从空气射到玻璃表面,那么这个参数应该是空气的折射率和玻璃的折射率之间的比值,即 1/1.5。它的返回值就是计算而得的折射方向,它的模则等于入射光线的模。

同样,我们也没有对 i.worldRefr 进行归一化操作,因为对立方体纹理的采样只需要提供方向即可。最后,我们使用 _RefractAmount 来混合漫反射颜色和折射颜色,并和环境光照相加后返回。

折射效果

菲涅耳反射

在实时渲染中,我们经常会使用**菲涅耳反射(Fresnel reflection)**来根据视角方向控制反射程度。通俗地讲,菲涅耳反射描述了一种光学现象,即当光线照射到物体表面上时,一部分发生反射,一部分进入物体内部,发生折射或散射。被反射的光和入射光之间存在一定的比率关系,这个比率关系可以通过菲涅耳等式进行计算。真实世界的菲涅耳等式是非常复杂的,但在实时渲染中,我们通常会用一些近似公式来计算。其中一个著名的近似公式就是 Schlick 菲涅耳近似等式
$$
F_{Schlick}(v, n) = F_0 + (1 - F_0)(1 - v \cdot n)^5
$$

其中,$F_0$ 是一个反射系数,用于控制菲涅耳反射的强度,$v$ 是视角方向,$n$ 是表面法线。另一个应用比较广泛的等式是 Empricial 菲涅耳近似等式
$$
F_{Empricial}(v, n) = \max(0, \min(1, bias + scale \times (1 - v \cdot n)^{power}))
$$

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

使用上面的菲涅耳近似等式,我们可以在边界处模拟反射光强和折射光强/漫反射光强之间的变化。在许多车漆、水面等材质的渲染中,我们会经常使用菲涅耳反射来模拟更加真实的反射效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
// Upgrade NOTE: replaced '_Object2World' with 'unity_ObjectToWorld'

Shader "Custom/Chapter10/Chapter10-Fresnel"
{
Properties
{
_Color ("Color Tint", Color) = (1, 1, 1, 1)
_FresnelScale ("Fresnel Scale", Range(0, 1)) = 0.5
_Cubemap ("Reflection Cubemap", Cube) = "_Skybox" {}

}

SubShader
{
Tags { "RenderType"="Opaque" "Queue"="Geometry" }

Pass {
Tags { "LightMode"="ForwardBase" }

CGPROGRAM

#pragma multi_compile_fwdbase
#pragma vertex vert
#pragma fragment frag

#include "Lighting.cginc"
#include "AutoLight.cginc"

fixed4 _Color;
fixed _FresnelScale;
samplerCUBE _Cubemap;

struct a2v {
float4 vertex : POSITION;
float3 normal : NORMAL;
};

struct v2f {
float4 pos : SV_POSITION;
float3 worldPos : TEXCOORD0;
fixed3 worldNormal : TEXCOORD1;
fixed3 worldViewDir : TEXCOORD2;
fixed3 worldRefl : TEXCOORD3;
SHADOW_COORDS(4)
};

v2f vert(a2v v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
o.worldViewDir = UnityWorldSpaceViewDir(o.worldPos);
o.worldRefl = reflect(-o.worldViewDir, o.worldNormal);
TRANSFER_SHADOW(o);
return o;
}

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;

fixed fresnel = _FresnelScale + (1 - _FresnelScale) * 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);
}

ENDCG
}
}
FallBack "Reflective/VertexLit"
}

在上面的代码中,我们使用 Schlick 菲涅耳近似等式来计算 fresnel 变量,并使用它来混合漫反射光照和反射光照。一些实现也会直接把 fresnel 和反射光照相乘后叠加到漫反射光照上,模拟边缘光照的效果。

菲涅耳反射效果


高级纹理(一)
http://example.com/posts/高级纹理(一)/
作者
祭零小白
发布于
2022年2月23日
许可协议