版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
我们在第7 章学习了关于基础纹理的内容,这些纹理包括法线纹理、渐变纹理和遮罩纹理等。这些纹理尽管用处不同,但它们都属于低维〈一维或二维)纹理。在本章中,我们将学习一些更复杂的纹理。
在10.1 节中,我们会学习如何使用立方体纹理( Cubemap )实现环境映射。
然后,我们会在10.2 节介绍一类特殊的纹理一一渲染纹理(Render Texture ),我们会发现渲染纹理是多么的强大。
最后, 10.3 节将介绍程序纹理( Procedure Texture )。
10.1 立方体纹理
在图形学中, 立方体纹理( Cubemap ) 是环境映射(Environment Mapping )的一种实现方法。环境映射可以模拟物体周围的环境,而使用了环境映射的物体可以看起来像镀了层金属一样反射出周围的环境。
和之前见到的纹理不同,立方体纹理一共包含了6 张图像,这些图像对应了一个立方体的6个面,立方体纹理的名称也由此而来。立方体的每个面表示沿着世界空间下的轴向(上、下、左、右、前、后)观察所得的图像。那么,我们如何对这样一种纹理进行采样呢?和之前使用二维纹理坐标不同,对立方体纹理来样我们需要提供一个三维的纹理坐标,这个三维纹理坐标表示了我们在世界空间下的一个3D方向。这个方向矢量从立方体的中心出发,当它向外部延伸时就会和立方体的6 个纹理之一发生相交,而采样得到的结果就是由该交点计算而来的。图10.1 给出了使用方向矢量对立方体纹理采样的过程。
和之前见到的纹理不同,立方体纹理一共包含了6 张图像,这些图像对应了一个立方体的6个面,立方体纹理的名称也由此而来。立方体的每个面表示沿着世界空间下的轴向(上、下、左、右、前、后)观察所得的图像。那么,我们如何对这样一种纹理进行采样呢?和之前使用二维纹理坐标不同,对立方体纹理来样我们需要提供一个三维的纹理坐标,这个三维纹理坐标表示了我们在世界空间下的一个3D方向。这个方向矢量从立方体的中心出发,当它向外部延伸时就会和立方体的6 个纹理之一发生相交,而采样得到的结果就是由该交点计算而来的。图10.1 给出了使用方向矢量对立方体纹理采样的过程。
![](https://i-blog.csdnimg.cn/blog_migrate/0384276d5fc06baecde776aa3669e837.png)
立方体纹理在实时渲染中有很多应用,最常见的是用于天空盒子( Skybox)以及环境映射。
10.1.1 天空盒子
天空盒子( Skybox ) 是游戏中用于模拟背景的一种方法。天空盒子这个名字包含了两个信息:它是用来模拟天空的(尽管现在我们仍可以用它模拟室内等背景〉,它是一个盒子。当我们在场景中使用了天空盒子时,整个场景就被包围在一个立方体内。这个立方体的每个面使用的技术就是立方体纹理映射技术。
在Unity 中,想要使用天空盒子非常简单。我们只需要创建一个Skybox 材质,再把它赋给该场景的相关设置即可。
在Unity 中,想要使用天空盒子非常简单。我们只需要创建一个Skybox 材质,再把它赋给该场景的相关设置即可。
我们首先来看如何创建一个Skybox 材质。
(1)新建一个材质,在本书资源中该材质名为SkyboxMat。
(2)在SkyboxMat 的Unity Shader 下拉菜单中选择Unity 自带的Skybox/6 Sided,该材质需要6 张纹理。
(3)使用本书资源中的Assets/Textures/Chapter10/Cubemaps 文件夹下的6 张纹理对第2 步中的材质赋值,注意这6 张纹理的正确位置(如posz 纹理对应了Front [+Z]属性〉。为了让天空盒子正常渲染,我们需要把这6 张纹理的Wrap Mode设置为Clamp , 以防止在接缝处出现不匹配的现象。
上述步骤得到的材质如图10.2 所示。
上述步骤得到的材质如图10.2 所示。
![](https://i-blog.csdnimg.cn/blog_migrate/a45f155d7c1f445ac7e676c9df79a4d1.png)
Exposure ,用于调整天空盒子的亮度;Rotation ,用于调整天空盒子沿+y 轴方向的旋转角度。
下面,我们来看一下如何为场景添加Skybox。
( 1 )新建一个场景,在本书资源中该场景名为Scene_10_1_1。
下面,我们来看一下如何为场景添加Skybox。
( 1 )新建一个场景,在本书资源中该场景名为Scene_10_1_1。
( 2)在Window → Lighting 菜单中,把SkyboxMat 赋给Skybox 选项,如图10.3 所示。
![](https://i-blog.csdnimg.cn/blog_migrate/0c6dcb995f3ba77596c7912d52977a99.png)
![](https://i-blog.csdnimg.cn/blog_migrate/38faf169e7a14dfce07a4ef0cad5fdba.png)
需要说明的是, 在Window → Lighting → Skybox 中设置的天空盒子会应用于该场景中的所有摄像机。如果我们希望某些摄像机可以使用不同的天空盒子,可以通过向该摄像机添加Skybox组件来覆盖掉之前的设置。也就是说,我们可以在摄像机上单击Component → Rendering →Skybox 来完成对场景默认天空盒子的覆盖。
在Unity 中,天空盒子是在所有不透明物体之后渲染的,而其背后使用的网格是一个立方体或一个细分后的球体。
在Unity 中,天空盒子是在所有不透明物体之后渲染的,而其背后使用的网格是一个立方体或一个细分后的球体。
10.1.2 创建用于环境映射的立方体纹理
除了天空盒子,立方体纹理最常见的用处是用于环境映射。通过这种方法,我们可以模拟出金属质感的材质。在Unity 5 中,创建用于环境映射的立方体纹理的方法有三种: 第一种方法是直接由一些特殊布局的纹理创建;第二种方法是手动创建一个Cubemap 资源,再把6 张图赋给它;第三种方法是由脚本生成。
如果使用第一种方法,我们需要提供一张具有特殊布局的纹理,例如类似立方体展开图的交叉布局、全景布局等。然后,我们只需要把该纹理的Texture Type 设置为Cubemap 即可, Unity会为我们做好剩下的事情。在基于物理的渲染中,我们通常会使用一张HDR 图像来生成高质量的Cubemap(详见第18 章)。读者可在官方文档( http://docs.unity3d.com/Manual/class-Cubemap.html )
中找到更多的资料。
第二种方法是Unity 5 之前的版本中使用的方法。我们首先需要在项目资源中创建一个Cubemap,然后把6 张纹理拖曳到它的面板中。在Unity 5 中,官方推荐使用第一种方法创建立方体纹理,这是因为第一种方法可以对纹理数据进行压缩,而且可以支持边缘修正、光滑反射( glossy reflection )和HDR 等功能。
前面两种方法都需要我们提前准备好立方体纹理的图像,它们得到的立方体纹理往往是被场景中的物体所共用的。但在理想情况下,我们希望根据物体在场景中位置的不同,生成它们各自不同的立方体纹理。这时,我们就可以在Unity 中使用脚本来创建。这是通过利用Unity 提供的Camera.RenderToCubemap 函数来实现的。Camera.RenderToCubemap 函数可以把从任意位置观察到的场景图像存储到6 张图像中,从而创建出该位置上对应的立方体纹理。
在Unity 的脚本手册( http://docs.unityd.com/ScriptReference/Camera.RenderToCubemap.html )中给出了如何使用Camera.RenderToCubemap 函数来创建立方体纹理的代码。读者也可以在本书资源的
Assets/Editor/Chapter10/RenderCubemapWizard.cs 中找到相关代码。其中关键代码如下:
-
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 );
-
}
在上面的代码中,我们在renderFromPosition (由用户指定〉位置处动态创建一个摄像机,并调用Camera.RenderToCubemap 函数把从当前位置观察到的图像渲染到用户指定的立方体纹理cubemap 中,完成后再销毁临时摄像机。由于该代码需要添加菜单栏条目,因此我们需要把它放在Editor 文件夹下才能正确执行。
当准备好上述代码后,要创建一个Cubemap 非常简单。
(1)我们使用和10.1.1 节中相同的场景,并创建一个空的GameObject 对象。我们会使用该GameObject 的位置信息来渲染立方体纹理。
( 2)新建一个用于存储的立方体纹理(在Project 视图下单击右键,选择Create → Legacy →Cubemap 来创建〉。在本书资源中,该立方体纹理名为Cubemap_0。 为了让脚本可以顺利将图像渲染到该立方体纹理中,我们需要在它的面版中勾选 Readable 选项。
(3 )从Unity 菜单栏选择GameObject -> Render into Cubemap,打开我们在脚本中实现的用于渲染立方体纹理的窗口,并把第1 步中创建的GameObject 和第2 步中创建的Cubemap_0 分别拖曳到窗口中的 Render From Position和 Cubemap 选项,如图10.5 所示。
![](https://i-blog.csdnimg.cn/blog_migrate/abf8a9741c5044e7148571b167e3a8c2.png)
![](https://i-blog.csdnimg.cn/blog_migrate/6d09ea029bba665f1a6f6f7767896829.png)
需要注意的是,我们需要为Cubemap 设置大小,即图10.6 中的Face size 选项。Face size 值越大,渲染出来的立方体纹理分辨率越大,效果可能更好,但需要占用的内存也越大,这可以由面饭最下方显示的内存大小得到。
准备好了需要的立方体纹理后,我们就可以对物体使用环境映射技术。而环境映射最常见的应用就是反射和折射。
准备好了需要的立方体纹理后,我们就可以对物体使用环境映射技术。而环境映射最常见的应用就是反射和折射。
10.1.3 反射
使用了反射效果的物体通常看起来就像镀了层金属。想要模拟反射效果很简单,我们只需要通过入射光线的方向和表面法线方向来计算反射方向,再利用反射方向对立方体纹理采样即可。
在学习完本节后,我们可以得到类似图10.7 中的效果。
在学习完本节后,我们可以得到类似图10.7 中的效果。
![](https://i-blog.csdnimg.cn/blog_migrate/74e76f3c9fd7c27217e7fd6690eaee8b.png)
( 1)新建一个场景,在本书资源中,该场景名为Scene_ 10_1_3 。我们替换掉Unity 5 中场景默认的天空盒子,而把10.1.1 节中创建的天空盒子材质拖曳到Window → Lighting → Skybox 选项中(当然,我们也可以为摄像机添加Skybox 组件来覆盖默认的天空盒子〉。
(2)向场景中拖曳一个Teapot 模型,并调整它的位置和10.1.2 节中创建Cubemap_0 时使用的空GameObject 的位置相同。
(3)新建一个材质,在本书资源中,该材质名为ReflectionMat,把材质赋给第2 步中创建的Teapot 模型。
(4)新建一个Unity Shader ,在本书资源中,该Shader 名为Chapter10-Reflection 。把Chapter10-Reflection 赋给第3 步中创建的材质。
反射的实现非常简单。打开Chapter10-Reflection,删除原有的代码, 进行如下关键修改。
( 1 )首先,我们声明了3 个新的属性:
反射的实现非常简单。打开Chapter10-Reflection,删除原有的代码, 进行如下关键修改。
( 1 )首先,我们声明了3 个新的属性:
-
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" {}
-
}
其中, _ReflectColor 用于控制反射颜色, _ReflectAmount 用于控制这个材质的反射程度,而 _Cubemap 就是用于模拟反射的环境映射纹理。
( 2 )我们在顶点着色器中计算了该顶点处的反射方向,这是通过使用CG 的 reflect 函数来实现的:
-
v2f vert(a2v v) {
-
v2f o;
-
-
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
-
-
o.worldNormal = UnityObjectToWorldNormal(v.normal);
-
-
o.worldPos = mul(_Object2World, v.vertex).xyz;
-
-
o.worldViewDir = UnityWorldSpaceViewDir(o.worldPos);
-
-
// Compute the reflect dir in world space
-
o.worldRefl = reflect(-o.worldViewDir, o.worldNormal);
-
-
TRANSFER_SHADOW(o);
-
-
return o;
-
}
物体反射到摄像机中的光线方向,可以由光路可逆的原则来反向求得。也就是说,我们可以计算视角方向关于顶点法线的反射方向来求得入射光线的方向。
(3)在片元着色器中,利用反射方向来对立方体纹理采样:
-
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));
-
-
// Use the reflect dir in world space to access the cubemap
-
fixed3 reflection = texCUBE(_Cubemap, i.worldRefl).rgb * _ReflectColor.rgb;
-
-
UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);
-
-
// Mix the diffuse color with the reflected color
-
fixed3 color = ambient + lerp(diffuse, reflection, _ReflectAmount) * atten;
-
-
return fixed4(color,
1.0);
-
}
对立方体纹理的采样需要使用CG 的
texCUBE函数。注意到, 在上面的计算中,我们在采样时并没有对 i.worldRefl 进行归一化操作。这是因为,用于采样的参数仅仅是作为方向变量传递给 texCUBE 函数的, 因此我们没有必要进行一次归一化的操作。然后,我们使用 _ReflectAmount 来混合漫反射颜色和反射颜色,并和环境光照相加后返回。
在上面的计算中,我们选择在顶点着色器中计算反射方向。当然,我们也可以选择在片元着色器中计算,这样得到的效果更加细腻。但是,对于绝大多数人来说这种差别往往是可以忽略不计的,因此出于性能方面的考虑,我们选择在顶点着色器中计算反射方向。
保存后返回场景,在材质面板中把Cubemap_0 拖曳到 Reflection Cubemap 属性中,并调整其他参数,即可得到类似图10.7 中的效果。
10.1.4 折射
在这一节中,我们将学习如何在Unity Shader 中模拟另一个环境映射的常见应用一一折射。折射的物理原理比反射复杂一些。我们在初中物理就已经接触过折射的定义:当光线从一种介质(例如空气〉斜射入另一种介质(例如玻璃〉时,传播方向一般会发生改变。当给定入射角时,我们可以使用斯涅尔定律( Snell’s Law )来计算反射角。当光从介质1 沿着和表面法线夹角为θ1的方向斜射入介质2 时,我们可以使用如下公式计算折射光线与法线的夹角θ2:
![](https://i-blog.csdnimg.cn/blog_migrate/641632e0c00eb848d663425e91d61464.png)
![](https://i-blog.csdnimg.cn/blog_migrate/1bf813dd3c1c9435a26cb5eb7f0d30c8.png)
在学习完本节后,我们可以得到类似图10.9 中的效果。
![](https://i-blog.csdnimg.cn/blog_migrate/8e4e1f259d9c47fb354890efa5723f5a.png)
为此,我们需要做如下准备工作。
(1)新建一个场景,在本书资源中,该场景名为Scene_10_1_4。我们替换掉Unity 5 中场景默认的天空盒子,而把10.1.1 节中创建的天空盒子材质拖曳到Window → Lighting → Skybox选项中(当然,我们也可以为摄像机添加Skybox 组件来覆盖默认的天空盒子〉。
(2)向场景中拖曳一个Teapot 模型,并调整它的位置。
(3)新建一个材质,在本书资源中,该材质名为RefractionMat,把材质赋给第2 步中创建的Teapot 模型。
(1)新建一个场景,在本书资源中,该场景名为Scene_10_1_4。我们替换掉Unity 5 中场景默认的天空盒子,而把10.1.1 节中创建的天空盒子材质拖曳到Window → Lighting → Skybox选项中(当然,我们也可以为摄像机添加Skybox 组件来覆盖默认的天空盒子〉。
(2)向场景中拖曳一个Teapot 模型,并调整它的位置。
(3)新建一个材质,在本书资源中,该材质名为RefractionMat,把材质赋给第2 步中创建的Teapot 模型。
(4)新建一个Unity Shader, 在本书资源中, 该Shader 名为Chapter10-Refraction 。把Chapter10-Refraction 赋给第3 步中创建的材质。
折射效果的实现略微复杂一些。打开Chapter10-Refraction, 删除原有的代码,进行如下关键修改。
(1 )首先,我们声明了4 个新属性:
折射效果的实现略微复杂一些。打开Chapter10-Refraction, 删除原有的代码,进行如下关键修改。
(1 )首先,我们声明了4 个新属性:
-
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" {}
-
}
其中,_RefractColor、_RefractAmount 和 _Cubemap 与10.1.3 节中控制反射时使用的属性类似。除此之外,我们还使用了一个属性 _RefractRatio ,我们需要使用该属性得到不同介质的透射比,以此来计算折射方向。
(2 )在顶点着色器中, 计算折射方向:
-
v2f vert(a2v v) {
-
v2f o;
-
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
-
-
o.worldNormal = UnityObjectToWorldNormal(v.normal);
-
-
o.worldPos = mul(_Object2World, v.vertex).xyz;
-
-
o.worldViewDir = UnityWorldSpaceViewDir(o.worldPos);
-
-
// Compute the refract dir in world space
-
o.worldRefr = refract(-normalize(o.worldViewDir), normalize(o.worldNormal), _RefractRatio);
-
-
TRANSFER_SHADOW(o);
-
-
return o;
-
}
我们使用了CG 的
refract函数来计算折射方向。它的第一个参数即为入射光线的方向,它必须是归一化后的矢量;第二个参数是表面法线,法线方向同样需要是归一化后的;第三个参数是入射光线所在介质的折射率和折射光线所在介质的折射率之间的比值,例如如果光是从空气射到玻璃表面,那么这个参数应该是空气的折射率和玻璃的折射率之间的比值, 即1/1.5 。它的返回值就是计算而得的折射方向,它的模则等于入射光线的模。
(3)然后,我们在片元着色器中使用折射方向对立方体纹理进行采样:
-
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));
-
-
// Use the refract dir in world space to access the cubemap
-
fixed3 refraction = texCUBE(_Cubemap, i.worldRefr).rgb * _RefractColor.rgb;
-
-
UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);
-
-
// Mix the diffuse color with the refract color
-
fixed3 color = ambient + lerp(diffuse, refraction, _RefractAmount) * atten;
-
-
return fixed4(color,
1.0);
-
}
同样,我们也没有对i.worldRefr 进行归一化操作,因为对立方体纹理的采样只需要提供方向即可。最后,我们使用 _RefractAmount 来混合漫反射颜色和折射颜色,并和环境光照相加后返回。
保存后返回场景,在材质面板中把 Cubemap_0 拖曳到 Reflection Cubemap 属性中,并调整其他参数,即可得到类似图10.9 中的效果。
10.1.5 菲涅耳反射
在实时渲染中,我们经常会使用菲涅耳反射( Fresnel reflection )来根据视角方向控制反射程度。通俗地讲,菲涅耳反射描述了一种光学现象,即当光线照射到物体表面上时,一部分发生反射,一部分进入物体内部,发生折射或散射。被反射的光和入射光之间存在一定的比率关系,这个比率关系可以通过菲涅耳等式进行计算。一个经常使用的例子是,当你站在湖边,直接低头看脚边的水面时,你会发现水几乎是透明的,你可以直接看到水底的小鱼和石子:但是,当你抬头看远处的水面时,会发现几乎看不到水下的情景,而只能看到水面反射的环境。这就是所谓的菲涅耳效果。事实上,不仅仅是水、玻璃这样的反光物体具有菲涅耳效果,几乎任何物体都或多或少包含了菲涅耳效果,这是基于物理的渲染中非常重要的一项高光反射计算因子(详见第18 章)。读者可以在John Hable 的一篇非常有名的文章Everything Has Fresnel (http://filmicgames.com/archives/557 )中看到现实生活中各种物体的菲涅耳效果。那么,我们如何计算菲涅耳反射呢?这就需要使用菲涅耳等式。真实世界的菲涅耳等式是非常复杂的,但在实时渲染中,我们通常会使用一些近似公式来计算。其中一个著名的近似公式就是Schlick 菲涅耳近似等式:
![](https://i-blog.csdnimg.cn/blog_migrate/a95ce48eae77c73395fe8b7efd815143.png)
使用上面的菲涅耳近似等式,我们可以在边界处模拟反射光强和折射光强/漫反射光强之间的变化。在许多车漆、水面等材质的渲染中,我们会经常使用菲涅耳反射来模拟更加真实的反射效果。
在本节中,我们将使用Schlick 菲涅耳近似等式来模拟菲涅耳反射。在本节最后,我们可以得到类似图10.10 中的效果。注意图中在模型边界处的反射现象。
![](https://i-blog.csdnimg.cn/blog_migrate/6db3ef7616ded0cebc6099ff85dca49e.png)
(1 )新建一个场景,在本书资源中,该场景名为Scene_10_1_5 。我们替换掉Unity 5 中场景默认的天空盒子,而把10.1.1 节中创建的天空盒子材质拖曳到Window → Lighting → Skybox 选项中(当然,我们也可以为摄像机添加Skybox 组件来覆盖默认的天空盒子)。
(2 )向场景中拖曳一个Teapot 模型,并调整它的位置。
( 3)新建一个材质, 在本书资源中,该材质名为FresnelMat,把材质赋给第2 步中创建的Teapot 模型。
( 4)新建一个Unity Shader, 在本书资源中, 该Shader 名为Chapter10-Fresnel 。把Chapter10-Fresnel 赋给第3 步中创建的材质。
打开Chapter10-Fresnel,删除原有的代码,进行如下关键修改。
(1)首先, 我们在Properties 语义块中声明了用于调整菲涅耳反射的属性以及反射使用的Cubemap:
( 4)新建一个Unity Shader, 在本书资源中, 该Shader 名为Chapter10-Fresnel 。把Chapter10-Fresnel 赋给第3 步中创建的材质。
打开Chapter10-Fresnel,删除原有的代码,进行如下关键修改。
(1)首先, 我们在Properties 语义块中声明了用于调整菲涅耳反射的属性以及反射使用的Cubemap:
-
Properties {
-
_Color (
"Color Tint", Color) = (
1,
1,
1,
1)
-
_FresnelScale (
"Fresnel Scale", Range(
0,
1)) =
0.5
-
_Cubemap (
"Reflection Cubemap", Cube) =
"_Skybox" {}
-
}
(2)在顶点着色器中计算世界空间下的法线方向、视角方向和反射方向:
-
v2f vert(a2v v) {
-
v2f o;
-
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
-
-
o.worldNormal = UnityObjectToWorldNormal(v.normal);
-
-
o.worldPos = mul(_Object2World, v.vertex).xyz;
-
-
o.worldViewDir = UnityWorldSpaceViewDir(o.worldPos);
-
-
o.worldRefl = reflect(-o.worldViewDir, o.worldNormal);
-
-
TRANSFER_SHADOW(o);
-
-
return o;
-
}
(3 )在片元着色器中计算菲涅耳反射, 并使用结果值混合漫反射光照和反射光照:
-
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);
-
}
在上面的代码中,我们使用Schlick 菲涅耳近似等式来计算fresnel 变量, 并使用它来混合漫反射光照和反射光照。一些实现也会直接把fresnel 和反射光照相乘后叠加到漫反射光照上, 模拟边缘光照的效果。
保存后返回场景, 在材质面板中把Cubemap_0 拖曳到 Cubemap 属性中, 并调整其他参数,即可得到类似图10.10 中的效果。当我们把 _FresnelScale 调节到1 时,物体将完全反射 Cubemap中的图像;当 _FresnelScale 为0 时,则是一个具有边缘光照效果的漫反射物体。我们还会在15.2节中使用菲涅耳反射来混合反射和折射光照,以此来模拟一个简单的水面效果。
10.2 渲染纹理
在之前的学习中, 一个摄像机的渲染结果会输出到颜色缓冲中,并显示到我们的屏幕上。现代的GPU 允许我们把整个三维场景渲染到一个中间缓冲中,即渲染目标纹理( Render Target Texture, RTT ),而不是传统的帧缓冲或后备缓冲( back buffer )。与之相关的是多重渲染目标(Multiple Render Target, MRT ),这种技术指的是GPU 允许我们把场景同时渲染到多个渲染目标纹理中,而不再需要为每个渲染目标纹理单独渲染完整的场景。延迟渲染就是使用多重渲染目标的一个应用。Unity 为渲染目标纹理定义了一种专门的纹理类型一一渲染纹理(Render Texture ) 。在Unity中使用渲染纹理通常有两种方式: 一种方式是在Project 目录下创建一个渲染纹理,然后把某个摄像机的渲染目标设置成该渲染纹理,这样一来该摄像机的渲染结果就会实时更新到渲染纹理中,而不会显示在屏幕上。使用这种方法,我们还可以选择渲染纹理的分辨率、滤波模式等纹理属性。
另一种方式是在屏幕后处理时使用GrabPass 命令或OnRenderimage 函数来获取当前屏幕图像,Unity 会把这个屏幕图像放到一张和屏幕分辨率等同的渲染纹理中,下面我们可以在自定义的Pass 中把它们当成普通的纹理来处理,从而实现各种屏幕特效。我们将依次学习这两种方法在Unity中的实现( OnRenderlmage 函数会在第12 章中讲到) 。
10.2.1 镜子效果
在本节中,我们将学习如何使用渲染纹理来模拟镜子效果。学习完本节后,我们可以得到类似图10.11 中的效果。![](https://i-blog.csdnimg.cn/blog_migrate/2d1d70fb049e660c9fb6aec8ac119f56.png)
为此,我们需要做如下准备工作。
(1)新建一个场景。在本书资源中,该场景名为Scene_10_2_1 。在Unity 5.2 中,默认情况下场景将包含一个摄像机和一个平行光,并且使用了内置的天空盒子。在Window → Lighting → Skybox 中去掉场景中的天空盒子。
(2)新建一个材质。在本书资源中,该材质名为MirrorMat。
(3)新建一个Unity Shader。 在本书资源中,该Shader 名为Chapter10-Mirror。把新的Shader 赋给第2 步中创建的材质。
(4)在场景中创建6 个立方体,并调整它们的位置和大小,使得它们构成围绕着摄像机的房间的6 面墙。给它们赋予在9.5 节中创建的标准材质,并让它们的颜色互不相同。向场景中添加3个点光源,并调整它们的位置,使它们可以照亮整个房间。
(5)创建3 个球体和两个正方体,调整它们的位置和大小,并给它们赋予在9.5 节中创建的标准材质。这些物体将作为房间内的饰品。
(6)创建一个四边形( Quad ),调整它的位置和大小,它将作为镜子。把第2 步中创建的材质赋给它。
(7)在Project 视图下创建一个渲染纹理(右键单击Create → Render Texture ),在本书资源中,该渲染纹理名为MirrorTexture 。它使用的纹理设置如图10.12 右图所示。
(1)新建一个场景。在本书资源中,该场景名为Scene_10_2_1 。在Unity 5.2 中,默认情况下场景将包含一个摄像机和一个平行光,并且使用了内置的天空盒子。在Window → Lighting → Skybox 中去掉场景中的天空盒子。
(2)新建一个材质。在本书资源中,该材质名为MirrorMat。
(3)新建一个Unity Shader。 在本书资源中,该Shader 名为Chapter10-Mirror。把新的Shader 赋给第2 步中创建的材质。
(4)在场景中创建6 个立方体,并调整它们的位置和大小,使得它们构成围绕着摄像机的房间的6 面墙。给它们赋予在9.5 节中创建的标准材质,并让它们的颜色互不相同。向场景中添加3个点光源,并调整它们的位置,使它们可以照亮整个房间。
(5)创建3 个球体和两个正方体,调整它们的位置和大小,并给它们赋予在9.5 节中创建的标准材质。这些物体将作为房间内的饰品。
(6)创建一个四边形( Quad ),调整它的位置和大小,它将作为镜子。把第2 步中创建的材质赋给它。
(7)在Project 视图下创建一个渲染纹理(右键单击Create → Render Texture ),在本书资源中,该渲染纹理名为MirrorTexture 。它使用的纹理设置如图10.12 右图所示。
(8 )最后,为了得到从镜子出发观察到的场景图像,我们还需要创建一个摄像机,并调整它的位置、裁剪平面、视角等,使得它的显示图像是我们希望的镜子图像。由于这个摄像机不需要直接显示在屏幕上,而是用于渲染到纹理。因此,我们把第7 步中创建的MirrorTexture 拖曳到该摄像机的Target Texture 上。图10.12 显示了摄像机面版和渲染纹理的相关设置。
![](https://i-blog.csdnimg.cn/blog_migrate/34aeaf1a4aebd73e08e0a58d459b865f.png)
镜子实现的原理很简单,它使用一个渲染纹理作为输入属性,并把该渲染纹理在水平方向上翻转后直接显示到物体上即可。打开新建的Chaptert10-Mirror,删除所有已有代码,并进行如下关键修改。
( 1 )在Properties 语义块中声明一个纹理属性,它对应了由镜子摄像机渲染得到的渲染纹理:
-
Properties {
-
_MainTex (
"Main Tex",
2D) =
"white" {}
-
}
(2 )在顶点着色器中计算纹理坐标:
-
v2f vert(a2v v) {
-
v2f o;
-
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
-
-
o.uv = v.texcoord;
-
// Mirror needs to flip x
-
o.uv.x =
1 - o.uv.x;
-
-
return o;
-
}
在上面的代码中,我们翻转了x 分量的纹理坐标。这是因为,镜子里显示的图像都是左右相反的。
(3 )在片元着色器中对渲染纹理进行采样和输出:
-
fixed4 frag(v2f i) : SV_Target {
-
return tex2D(_MainTex, i.uv);
-
}
保存后返回场最,并把我们创建的MirrorTexture 渲染纹理拖曳到材质的Main Tex 属性中,就可以得到图10.11 中的效果。
在上面的实现中,我们把渲染纹理的分辨率大小设置为256 × 256。有时,这样的分辨率会使 图像模糊不清,此时我们可以使用更高的分辨率或更多的抗锯齿采样等。但需要注意的是,更高 的分辨率会影响带宽和性能,我们应当尽量使用较小的分辨率。
10.2.2 玻璃效果
在Unity 中,我们还可以在Unity Shader 中使用一种特殊的Pass 来完成获取屏幕图像的目的,这就是GrabPass。 当我们在Shader 中定义了一个GrabPass 后, Unity 会把当前屏幕的图像绘制在一张纹理中,以便我们在后续的Pass 中访问它。我们通常会使用GrabPass 来实现诸如玻璃等透明材质的模拟,与使用简单的透明混合不同,使用GrabPass 可以让我们对该物体后面的图像进行更复杂的处理,例如使用法线来模拟折射效果,而不再是简单的和原屏幕颜色进行混合。
需要注意的是,在使用GrabPass 的时候,我们需要额外小心物体的渲染队列设置。正如之前所说, GrabPass 通常用于渲染透明物体, 尽管代码里并不包含混合指令, 但我们往往仍然需要把物体的渲染队列设置成透明队列(即Queue”=”Transparent”〉。这样才可以保证当渲染该物体时,所有的不透明物体都己经被绘制在屏幕上, 从而获取正确的屏幕图像。
在本节中, 我们将会使用GrabPass 来模拟一个玻璃效果。在学习完本节后, 我们可以得到类似图10.13 中的效果。这种效果的实现非常简单,我们首先使用一张法线纹理来修改模型的法线信息,然后使用了10.1 节介绍的反射方法,通过一个Cubemap 来模拟玻璃的反射,而在模拟折射时,则使用了GrabPass 获取玻璃后面的屏幕图像,并使用切线空间下的法线对屏幕纹理坐标偏移后,再对屏幕图像进行采样来模拟近似的折射效果。
![](https://i-blog.csdnimg.cn/blog_migrate/33b5c848e0f2e30e9e7373c97867a866.png)
( 1)新建一个场景。在本书资源中,该场景名为Scene_10_2_2。在Unity 5 .2 中, 默认情况下场景将包含一个摄像机和一个平行光,并且使用了内置的天空盒子。在Window → Lighting→Sky box 中去掉场景中的天空盒子。
(2 )新建一个材质。在本书资源中, 该材质名为GlassRefractionMat。
(3 )新建一个Unity Shader。在本书资源中, 该Shader 名为Chapter10-GlassRefraction。把新的Unity Shader 赋给第2 步中创建的材质。
( 4 )构建一个测试玻璃效果的场景。在本书资源的实现中,我们构建了一个由6 面墙围成的封闭房间,并在房间中放置了一个立方体和一个球体,其中球体位于立方体内部, 这是为了模拟玻璃对内部物体的折射效果。把第2 步中创建的材质赋给立方体。
( 5 )为了得到本场景适用的环境映射纹理, 我们使用了10.1.2 节中实现的创建立方体纹理的脚本(通过Gameobject → Render into Cubemap 打开编辑窗口〉来创建它, 如图10.14 所示。在本书资源中,该Cubemap 名为Glass_Cubemap 。
![](https://i-blog.csdnimg.cn/blog_migrate/0e900488dea62cc9dfad4e25c245a677.png)
(1)首先,我们需要声明该Shader 使用的各个属性:
-
Properties {
-
_MainTex (
"Main Tex",
2D) =
"white" {}
-
_BumpMap (
"Normal Map",
2D) =
"bump" {}
-
_Cubemap (
"Environment Cubemap", Cube) =
"_Skybox" {}
-
_Distortion (
"Distortion", Range(
0,
100)) =
10
-
_RefractAmount (
"Refract Amount", Range(
0.0,
1.0)) =
1.0
-
}
其中, _MainTex 是该玻璃的材质纹理, 默认为白色纹理: _BumpMap 是玻璃的法线纹理;_Cubemap 是用于模拟反射的环境纹理: _Distortion 则用于控制模拟折射时图像的扭曲程度;_RefractAmount 用于控制折射程度, 当RefractAmount 值为0 时, 该玻璃只包含反射效果,当RefractAmount 值为1 时, 该玻璃只包括折射效果。
( 2 )定义相应的渲染队列,并使用GrabPass 来获取屏幕图像:
-
SubShader {
-
// We must be transparent, so other objects are drawn before this one.
-
Tags {
"Queue"=
"Transparent"
"RenderType"=
"Opaque" }
-
-
// This pass grabs the screen behind the object into a texture.
-
// We can access the result in the next pass as _RefractionTex
-
GrabPass {
"_RefractionTex" }
我们首先在SubShader 的标签中将渲染队列设置成Transparent,尽管在后面的RenderType 被设置为了Opaque。这两者看似矛盾, 但实际上服务于不同的需求。我们在之前说过, 把Queue设置成Transparent 可以确保该物体渲染时,其他所有不透明物体都已经被渲染到屏幕上了, 否则就可能无法正确得到“透过玻璃看到的图像”。而设置RenderType 则是为了在使用着色器替换
( Shader Replacement )时, 该物体可以在需要时被正确渲染。这通常发生在我们需要得到摄像机的深度和法线纹理时, 这将会在第13 章中学到。
随后,我们通过关键词GrabPass 定义了一个抓取屏幕图像的Pass。在这个Pass 中我们定义了一个字符串,该字符串内部的名称决定了抓取得到的屏幕图像将会被存入哪个纹理中。实际上,我们可以省略声明该字符串,但直接声明纹理名称的方法往往可以得到更高的性能, 具体原因可以参见本节最后的部分。
(3)定义渲染玻璃所需的Pass。为了在Shader 中访问各个属性, 我们首先需要定义它们对应的变量:
-
sampler2D _MainTex;
-
float4 _MainTex_ST;
-
sampler2D _BumpMap;
-
float4 _BumpMap_ST;
-
samplerCUBE _Cubemap;
-
float _Distortion;
-
fixed _RefractAmount;
-
sampler2D _RefractionTex;
-
float4 _RefractionTex_TexelSize;
需要注意的是,我们还定义了_RefractionTex 和 _RefractionTex_TexelSize 变量, 这对应了在使用GrabPass 时指定的纹理名称。_RefractionTex_TexelSize 可以让我们得到该纹理的纹素大小,例如一个大小为256 × 512 的纹理, 它的纹素大小为(1/256, 1/512) 。我们需要在对屏幕图像的采样坐标进行偏移时使用该变量。
( 4 ) 我们首先需要定义顶点着色器:
-
v2f vert (a2v v) {
-
v2f o;
-
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
-
-
o.scrPos = ComputeGrabScreenPos(o.pos);
-
-
o.uv.xy = TRANSFORM_TEX(v.texcoord, _MainTex);
-
o.uv.zw = TRANSFORM_TEX(v.texcoord, _BumpMap);
-
-
float3 worldPos = mul(_Object2World, v.vertex).xyz;
-
fixed3 worldNormal = UnityObjectToWorldNormal(v.normal);
-
fixed3 worldTangent = UnityObjectToWorldDir(v.tangent.xyz);
-
fixed3 worldBinormal = cross(worldNormal, worldTangent) * v.tangent.w;
-
-
o.TtoW0 = float4(worldTangent.x, worldBinormal.x, worldNormal.x, worldPos.x);
-
o.TtoW1 = float4(worldTangent.y, worldBinormal.y, worldNormal.y, worldPos.y);
-
o.TtoW2 = float4(worldTangent.z, worldBinormal.z, worldNormal.z, worldPos.z);
-
-
return o;
-
}
在进行了必要的顶点坐标变换后,我们通过调用内置的ComputeGrabScreenPos 函数来得到对应被抓取的屏幕图像的采样坐标。读者可以在UnityCG.cginc 文件中找到它的声明,它的主要代码和ComputeScreenPos 基本类似,最大的不同是针对平台差异造成的采样坐标问题(详见5.6.1 节〉进行了处理。接着,我们计算了 _MainTex 和 _BumpMap 的采样坐标,井把它们分别存储在一个
float4 类型变量的xy 和zw 分量中。由于我们需要在片元着色器中把法线方向从切线空间(由法线纹理来样得到〉变换到世界空间下,以便对Cubemap 进行采样,因此,我们需要在这里计算该顶点对应的从切线空间到世界空间的变换矩阵,并把该矩阵的每一行分别存储在TtoW0 、TtoW1 和 TtoW2 的xyz 分量中。这里面使用的数学方法就是,得到切线空间下的3 个坐标轴(xyz 轴分别对应了副切线、切线和法线的方向)在世界空间下的表示,再把它们依次按列组成一个变换矩阵即可。TtoW0 等值的w 轴同样被利用起来,用于存储世界空间下的顶点坐标。
(5 )然后,定义片元着色器:
-
fixed4 frag (v2f i) : SV_Target {
-
float3 worldPos = float3(i.TtoW0.w, i.TtoW1.w, i.TtoW2.w);
-
fixed3 worldViewDir = normalize(UnityWorldSpaceViewDir(worldPos));
-
-
// Get the normal in tangent space
-
fixed3 bump = UnpackNormal(tex2D(_BumpMap, i.uv.zw));
-
-
// Compute the offset in tangent space
-
float2 offset = bump.xy * _Distortion * _RefractionTex_TexelSize.xy;
-
i.scrPos.xy = offset * i.scrPos.z + i.scrPos.xy;
-
fixed3 refrCol = tex2D(_RefractionTex, i.scrPos.xy/i.scrPos.w).rgb;
-
-
// Convert the normal to world space
-
bump = normalize(half3(dot(i.TtoW0.xyz, bump), dot(i.TtoW1.xyz, bump), dot(i.TtoW2.xyz, bump)));
-
fixed3 reflDir = reflect(-worldViewDir, bump);
-
fixed4 texColor = tex2D(_MainTex, i.uv.xy);
-
fixed3 reflCol = texCUBE(_Cubemap, reflDir).rgb * texColor.rgb;
-
-
fixed3 finalColor = reflCol * (
1 - _RefractAmount) + refrCol * _RefractAmount;
-
-
return fixed4(finalColor,
1);
-
}
我们首先通过TtoW0 等变量的w 分量得到世界坐标,并用该值得到该片元对应的视角方向。随后,我们对法线纹理进行采样,得到切线空间下的法线方向。我们使用该值和 _Distortion 属性以及 _RefractionTex_TexelSize 来对屏幕图像的采样坐标进行偏移,模拟折射效果。Distortion 值越大,偏移量越大,玻璃背后的物体看起来变形程度越大。在这里,我们选择使用切线空间下的法线方向来进行偏移,是因为该空间下的法线可以反映顶点局部空间下的法线方向。随后,我们对scrPos 透视除法得到真正的屏幕坐标(原理可参见4.9. 3 节) , 再使用该坐标对抓取的屏幕图像 _RefractionTex 进行采样, 得到模拟的折射颜色。
之后,我们把法线方向从切线空间变换到了世界空间下(使用变换矩阵的每一行,即TtoW0 、TtoW1 和TtoW2,分别和法线方向点乘,构成新的法线方向〉,并据此得到视角方向相对于法线方向的反射方向。随后,使用反射方向对Cubemap 进行采样,并把结果和主纹理颜色相乘后得到反射颜色。
最后, 我们使用RefractAmount 属性对反射和折射颜色进行混合,作为最终的输出颜色。
完成后,我们把本书资源中的Glass_Diffuse.jpg 和Glass_Normal.jpg 文件赋给材质的 Main Tex和 Normal Map 属性,把之前创建的Glass_Cubemap 赋给Environment Cubemap 属性,再调整 _RefractAmount 属性即可得到类似图10.13 中的玻璃效果。
在前面的实现中,我们在GrabPass 中使用一个字符串指明了被抓取的屏幕图像将会存储在哪个名称的纹理中。实际上, GrabPass 支持两种形式。
- 直接使用GrabPass {} , 然后在后续的Pass 中直接使用 _GrabTexture 来访问屏幕图像。但是,当场景中有多个物体都使用了这样的形式来抓取屏幕时, 这种方法的性能消耗比较大,因为对于每一个使用它的物体, Unity 都会为它单独进行一次昂贵的屏幕抓取操作。但这种方法可以让每个物体得到不同的屏幕图像,这取决于它们的渲染队列及渲染它们时当前的屏幕缓冲中的颜色。
- 使用GrabPass {”TextureName” },正如本节中的实现,我们可以在后续的Pass 中使用TextureName 来访问屏幕图像。使用这种方法同样可以抓取屏幕,但Unity 只会在每一帧时为第一个使用名为TextureName 的纹理的物体执行一次抓取屏幕的操作,而这个纹理同样可以在其他Pass 中被访问。这种方法更高效,因为不管场景中有多少物体使用了该命令,每一帧中Unity 都只会执行一次抓取工作,但这也意味着所有物体都会使用同一张屏幕图像。不过,在大多数情况下这已经足够了。
10.2.3 渲染纹理vs. GrabPass
尽管GrabPass 和10.2.1 节中使用的渲染纹理+额外摄像机的方式都可以抓取屏幕图像,但它们之间还是有一些不同的。GrabPass 的好处在于实现简单,我们只需要在Shader 中写几行代码就可以实现抓取屏幕的目的。而要使用渲染纹理的话,我们首先需要创建一个渲染纹理和一个额外的摄像机,再把该摄像机的Render Target 设置为新建的渲染纹理对象,最后把该渲染纹理传递给相应的Shader 。
但从效率上来讲,使用渲染纹理的效率往往要好于GrabPass ,尤其在移动设备上。使用渲染纹理我们可以自定义渲染纹理的大小, 尽管这种方法需要把部分场景再次渲染一遍,但我们可以通过调整摄像机的渲染层来减少二次渲染时的场景大小,或使用其他方法来控制摄像机是否需要开启。而使用GrabPass 获取到的图像分辨率和显示屏幕是一致的,这意味着在一些高分辨率的设备上可能会造成严重的带宽影响。而且在移动设备上, GrabPass 虽然不会重新渲染场景, 但它往往需要CPU 直接读取后备缓冲(back buffer)中的数据,破坏了CPU 和GPU 之间的并行性,这是比较耗时的,甚至在一些移动设备上这是不支持的。
在Unity 5 中, Unity 引入了命令缓冲(Command Buffers ) 来允许我们扩展Unity 的渲染流水线。使用命令缓冲我们也可以得到类似抓屏的效果,它可以在不透明物体渲染后把当前的图像复制到一个临时的渲染目标纹理中,然后在那里进行一些额外的操作,例如模糊等,最后把图像传递给需要使用它的物体进行处理和显示。除此之外, 命令缓冲还允许我们实现很多特殊的效果,读者可以在Unity 官方手册的图像命令缓冲一文( http://docs.unity3d.com/ManuaVGraphicsCommandBuffers.html )中找到更多内容, Unity 还提供了一个示例工程供我们学习。
但从效率上来讲,使用渲染纹理的效率往往要好于GrabPass ,尤其在移动设备上。使用渲染纹理我们可以自定义渲染纹理的大小, 尽管这种方法需要把部分场景再次渲染一遍,但我们可以通过调整摄像机的渲染层来减少二次渲染时的场景大小,或使用其他方法来控制摄像机是否需要开启。而使用GrabPass 获取到的图像分辨率和显示屏幕是一致的,这意味着在一些高分辨率的设备上可能会造成严重的带宽影响。而且在移动设备上, GrabPass 虽然不会重新渲染场景, 但它往往需要CPU 直接读取后备缓冲(back buffer)中的数据,破坏了CPU 和GPU 之间的并行性,这是比较耗时的,甚至在一些移动设备上这是不支持的。
在Unity 5 中, Unity 引入了命令缓冲(Command Buffers ) 来允许我们扩展Unity 的渲染流水线。使用命令缓冲我们也可以得到类似抓屏的效果,它可以在不透明物体渲染后把当前的图像复制到一个临时的渲染目标纹理中,然后在那里进行一些额外的操作,例如模糊等,最后把图像传递给需要使用它的物体进行处理和显示。除此之外, 命令缓冲还允许我们实现很多特殊的效果,读者可以在Unity 官方手册的图像命令缓冲一文( http://docs.unity3d.com/ManuaVGraphicsCommandBuffers.html )中找到更多内容, Unity 还提供了一个示例工程供我们学习。
10.3 程序纹理
程序纹理( Procedural Texture )指的是那些由计算机生成的图像,我们通常使用一些特定的算法来创建个性化图案或非常真实的自然元素,例如木头、石子等。使用程序纹理的好处在于我们可以使用各种参数来控制纹理的外观,而这些属性不仅仅是那些颜色属性,甚至可以是完全不同类型的图案属性,这使得我们可以得到更加丰富的动画和视觉效果。在本节中,我们首先会尝试用算法来实现一个非常简单的程序材质。然后,我们会介绍Unity 里一类专门使用程序纹理的材质一一程序材质。10.3.1 在Unity 中实现简单的程序纹理
在这一节里,我们会使用一个算法来生成一个波点纹理,如图10.15 所示。我们可以在脚本中调整一些参数,如背景颜色、被点颜色等,以控制最终生成的纹理外观。![](https://i-blog.csdnimg.cn/blog_migrate/8533de3553e26e0043de8e1201413660.png)
为此,我们需要进行如下准备工作。·
(1)新建一个场景。在本书资源中,该场景名为Scene_10_3_1 。在Unity 5.2 中,默认情况下场景将包含一个摄像机和一个平行光,并且使用了内置的天空盒子。在Window → Lighting → Skybox 中去掉场景中的天空盒子。
(2)新建一个材质。在本书资源中,该材质名为ProceduralTextureMat。
(3)我们使用第7 章的一个Unity Shader一一Chapter7-SingleTexture,把它赋给第2 步中创建的材质。
(4)新建一个立方体,并把第2 步中的材质赋给它。
(5)我们并没有为ProceduralTextureMat 材质赋予任何纹理,这是因为,我们想要用脚本来创建程序纹理。为此,我们再创建一个脚本ProceduralTextureGeneration.cs,并把它拖曳到第4 步创建的立方体。
在本节中,我们将会使用代码来生成一个波点纹理。为此,我们打开ProceduralTextureGeneration.cs进行如下修改。
(1)为了让该脚本能够在编辑器模式下运行,我们首先在类的开头添加如下代码:
(1)新建一个场景。在本书资源中,该场景名为Scene_10_3_1 。在Unity 5.2 中,默认情况下场景将包含一个摄像机和一个平行光,并且使用了内置的天空盒子。在Window → Lighting → Skybox 中去掉场景中的天空盒子。
(2)新建一个材质。在本书资源中,该材质名为ProceduralTextureMat。
(3)我们使用第7 章的一个Unity Shader一一Chapter7-SingleTexture,把它赋给第2 步中创建的材质。
(4)新建一个立方体,并把第2 步中的材质赋给它。
(5)我们并没有为ProceduralTextureMat 材质赋予任何纹理,这是因为,我们想要用脚本来创建程序纹理。为此,我们再创建一个脚本ProceduralTextureGeneration.cs,并把它拖曳到第4 步创建的立方体。
在本节中,我们将会使用代码来生成一个波点纹理。为此,我们打开ProceduralTextureGeneration.cs进行如下修改。
(1)为了让该脚本能够在编辑器模式下运行,我们首先在类的开头添加如下代码:
-
[ExecuteInEditMode]
-
public
class ProceduralTextureGeneration : MonoBehaviour {
(2 )声明一个材质,这个材质将使用该脚本中生成的程序纹理:
public Material material = null;
(3 )然后,声明该程序纹理使用的各种参数:
-
#region Material properties
-
[SerializeField, SetProperty(
"textureWidth")]
-
private
int m_textureWidth =
512;
-
public
int textureWidth {
-
get {
-
return m_textureWidth;
-
}
-
set {
-
m_textureWidth = value;
-
_UpdateMaterial();
-
}
-
}
-
-
[SerializeField, SetProperty(
"backgroundColor")]
-
private Color m_backgroundColor = Color.white;
-
public Color backgroundColor {
-
get {
-
return m_backgroundColor;
-
}
-
set {
-
m_backgroundColor = value;
-
_UpdateMaterial();
-
}
-
}
-
-
[SerializeField, SetProperty(
"circleColor")]
-
private Color m_circleColor = Color.yellow;
-
public Color circleColor {
-
get {
-
return m_circleColor;
-
}
-
set {
-
m_circleColor = value;
-
_UpdateMaterial();
-
}
-
}
-
-
[SerializeField, SetProperty(
"blurFactor")]
-
private
float m_blurFactor =
2.0f;
-
public
float blurFactor {
-
get {
-
return m_blurFactor;
-
}
-
set {
-
m_blurFactor = value;
-
_UpdateMaterial();
-
}
-
}
-
#endregion
#region 和
#endregion 仅仅是为了组织代码,并没有其他作用。由于我们生成的纹理是由若干圆点构成的,因此在上面的代码中,我们声明了4 个纹理属性:纹理的大小,数值通常是2 的整数幂;纹理的背景颜色;圆点的颜色:模糊因子,这个参数是用来模糊圆形边界的。注意到,对于每个属性我们使用了get/set 的方法,为了在面板上修改属性时仍可以执行set 函数,我们使用了一个开源插件 SetProperty, ( https://github.com/LMNRY/SetProperty/blob/master/Scripts/SetPropertyExample.cs )。这使得当我们修改了材质属性时,可以执行 _UpdateMaterial 函数来使用新的属性重新生成程序纹理。
(4)为了保存生成的程序纹理,我们声明一个Texture2D 类型的纹理变量:
private Texture2D m_generatedTexture = null;
( 5 )下面开始编写各个函数。首先, 我们需要在Start 函数中进行相应的检查,以得到需要使用该程序纹理的材质:
-
// Use this for initialization
-
void Start () {
-
if (material == null) {
-
Renderer renderer = gameObject.GetComponent<Renderer>();
-
if (renderer == null) {
-
Debug.LogWarning(
"Cannot find a renderer.");
-
return;
-
}
-
-
material = renderer.sharedMaterial;
-
}
-
-
_UpdateMaterial();
-
}
在上面的代码里,我们首先检查了material 变量是否为空,如果为空,就尝试从使用该脚本所在的物体上得到相应的材质。完成后, 调用 _UpdateMaterial 函数来为其生成程序纹理。
( 6 ) _UpdateMaterial 函数的代码如下:
-
private
void _UpdateMaterial() {
-
if (material != null) {
-
m_generatedTexture = _GenerateProceduralTexture();
-
material.SetTexture(
"_MainTex", m_generatedTexture);
-
}
-
}
它确保material 不为空, 然后调用 _GenerateProceduralTexture 函数来生成一张程序纹理, 并赋给 m_ generatedTexture 变量。完成后, 利用Material.SetTexture 函数把生成的纹理赋给材质。材质material 中需要有一个名为 _MainTex 的纹理属性。
( 7 ) _GenerateProceduralTexture 函数的代码如下:
-
private Color _MixColor(Color color0, Color color1,
float mixFactor) {
-
Color mixColor = Color.white;
-
mixColor.r = Mathf.Lerp(color0.r, color1.r, mixFactor);
-
mixColor.g = Mathf.Lerp(color0.g, color1.g, mixFactor);
-
mixColor.b = Mathf.Lerp(color0.b, color1.b, mixFactor);
-
mixColor.a = Mathf.Lerp(color0.a, color1.a, mixFactor);
-
return mixColor;
-
}
-
-
private Texture2D _GenerateProceduralTexture() {
-
Texture2D proceduralTexture =
new Texture2D(textureWidth, textureWidth);
-
-
// The interval between circles
-
float circleInterval = textureWidth /
4.0f;
-
// The radius of circles
-
float radius = textureWidth /
10.0f;
-
// The blur factor
-
float edgeBlur =
1.0f / blurFactor;
-
-
for (
int w =
0; w < textureWidth; w++) {
-
for (
int h =
0; h < textureWidth; h++) {
-
// Initalize the pixel with background color
-
Color pixel = backgroundColor;
-
-
// Draw nine circles one by one
-
for (
int i =
0; i <
3; i++) {
-
for (
int j =
0; j <
3; j++) {
-
// Compute the center of current circle
-
Vector2 circleCenter =
new Vector2(circleInterval * (i +
1), circleInterval * (j +
1));
-
-
// Compute the distance between the pixel and the center
-
float dist = Vector2.Distance(
new Vector2(w, h), circleCenter) - radius;
-
-
// Blur the edge of the circle
-
Color color = _MixColor(circleColor,
new Color(pixel.r, pixel.g, pixel.b,
0.0f), Mathf.SmoothStep(
0f,
1.0f, dist * edgeBlur));
-
-
// Mix the current color with the previous color
-
pixel = _MixColor(pixel, color, color.a);
-
}
-
}
-
-
proceduralTexture.SetPixel(w, h, pixel);
-
}
-
}
-
-
proceduralTexture.Apply();
-
-
return proceduralTexture;
-
}
代码首先初始化一张二维纹理,并且提前计算了一些生成纹理时需耍的变量。然后,使用了一个两层的嵌套循环遍历纹理中的每个像素,并在纹理上依次绘制9 个圆形。最后,调用 Texture2D.Apply 函数来强制把像素值写入纹理中,并返回该程序纹理。
保存脚本后返回场景,调整相应的参数后可以得到类似图10.15 中的效果。我们可以调整脚本面版中的材质参数来得到不同的程序纹理,如图10.16 所示。
![](https://i-blog.csdnimg.cn/blog_migrate/df182d6c05a26fae7e9136479f17b85e.png)
至此,我们已经学会如何通过脚本来创建一个程序纹理,再赋给相应的材质了。
10.3.2 Unity 的程序材质
在Unity 中,有一类专门使用程序纹理的材质,叫做程序材质( Procedural Materials )。这类材质和我们之前使用的那些材质在本质上是一样的,不同的是,它们使用的纹理不是普通的纹理,而是程序纹理。需要注意的是,程序材质和它使用的程序纹理并不是在Unity 中创建的,而是使用了一个名为Substance Designer 的软件在Unity 外部生成的。Substance Designer 是一个非常出色的纹理生成工具,很多3A 的游戏项目都使用了由它生成的材质。我们可以从Unity 的资源商店或网络中获取到很多免费或付费的Substance 材质。这些材质都是以sbsar 为后缀的,如图10.17 所示(资源来源于
https://www.assetstore.unity3d.com/en/#!/content/1352 ) 。我们可以直接把这些材质像其他资源一样拖入Unity 项目中。
![](https://i-blog.csdnimg.cn/blog_migrate/b9d2d474d54ee0e0f21a9c9dfb77063d.png)
当把这些文件导入Unity 后, Unity 就会生成一个程序纹理资源( Procedural Material Asset ) 。程序纹理资源可以包含一个或多个程序材质,例如图10.18 中就包含了两个程序纹理一一Cereals 和 Cereals_1,每个程序纹理使用了不同的纹理参数,因此Unity 为它们生成了不同的程序纹理,例如Cereals_Diffuse 和Cereals_1_Diffuse 等。
![](https://i-blog.csdnimg.cn/blog_migrate/6a6ab860c59bf38d1f77e8207661fe7b.png)
通过单击程序材质,我们可以在程序纹理的面板上看到该材质使用的Unity Shader 及其属性、生成程序纹理使用的纹理属性、材质预览等信息。
程序材质的使用和普通材质是一样的,我们把它们拖曳到相应的模型上即可。读者可以在本书资源的Scene_1 0_3_2 中找到这样的示例场景。程序纹理的强大之处很大原因在于它的多变性,我们可以通过调整程序纹理的属性来控制纹理的外观,甚至可以生成看似完全不同的纹理。图10.19 给出了调整Cereals 程序材质的不同纹理属性得到的不同材质效果。
程序材质的使用和普通材质是一样的,我们把它们拖曳到相应的模型上即可。读者可以在本书资源的Scene_1 0_3_2 中找到这样的示例场景。程序纹理的强大之处很大原因在于它的多变性,我们可以通过调整程序纹理的属性来控制纹理的外观,甚至可以生成看似完全不同的纹理。图10.19 给出了调整Cereals 程序材质的不同纹理属性得到的不同材质效果。
![](https://i-blog.csdnimg.cn/blog_migrate/fdc40840ca513d67a20f166d0ef7d8f6.png)
可以看出, 程序材质的自由度很高,而且可以和Shader 配合得到非常出色的视觉效果,它是一种非常强大的材质类型。