Unity Shaders and Effets Cookbook
《着色器和屏幕特效制作攻略》
这本书可以让你学习到如何使用着色器和屏幕特效让你的Unity工程拥有震撼的渲染画面。
------ Kenny Lammers
[第1节. 在 Unity3D 中创建立方体贴图](#第1节. 在 Unity3D 中创建立方体贴图)
[第2节. Unity3D 中实现简单的立方体贴图反射](#第2节. Unity3D 中实现简单的立方体贴图反射)
[第3节. Unity3D 中实现遮罩反射](#第3节. Unity3D 中实现遮罩反射)
[第4节. Unity3D 中实现法线贴图和反射](#第4节. Unity3D 中实现法线贴图和反射)
[第5节. Unity3D 中实现菲涅耳反射](#第5节. Unity3D 中实现菲涅耳反射)
[第6节. 在 Unity3D 中创建简单的动态立方体贴图系统](#第6节. 在 Unity3D 中创建简单的动态立方体贴图系统)
I like this book!
第四章:反射你的世界

当今,反射是可以让一个 Shader 在感官上具备视觉冲击力的一项关键技术,它是在 Shader 表面上模拟环境反射的一个过程,在这个过程中使用了你周围的世界(环境)信息,并且让 Shader 去反射这些世界(环境)的信息。因为在这部分中我们需要使用一种称为 Cubemap 的新型纹理。这种类型的纹理由 6 张纹理组成,并且用这 6 张纹理以立方体的方式围绕当前表面。所以想象一个立方体,立方体的6个面上被赋予了这6张纹理。这将允许我们去捕获周围环境并将其烘焙到纹理中。
我们将会了解到如何从我们自己的环境生成我们自己的立方体贴图,也会去研究使用这些生成的立方体贴图在着色器中来创建不同的反射效果。这非常适合模拟金属、汽车上的漆甚至还有塑料。因此,在本章中,我们将学习以下内容:
- 在 Unity3D 中创建立方体贴图
- Unity3D 中简单的立方体贴图反射
- Unity3D 中的遮罩反射
- Unity3D 中的法线贴图和反射
- Unity3D 中的菲涅耳反射
- 在 Unity3D 中创建简单的动态立方体贴图系统
第1节. 在 Unity3D 中创建立方体贴图

为了学习如何在 Shader 中创建反射的效果,我们首先需要学习如何创建自己的立方体贴图(Cubemaps)。你也可以去搜索一些现有的立方体贴图,但很快你会发现你还是需要自己去制作它,因为找的立方体贴图和你的游戏环境是不匹配的。一般情况下在线找的 Cubemap 都仅用于测试效果。一旦在项目中正式使用的时候,还是需要创建一个自己的立方体贴图才能用来去反射你游戏世界的环境,这是创建一个真实反射效果的关键。
我们将介绍几种可以直接在 Unity 编辑器中执行此操作的方法。此外,也会介绍如何创建自己的立方体贴图的独立应用程序。这有利于下一小节知识的推进,因为清楚立方体贴图的生成方式并理解它们,这对对整个章节内容是至关重要的。
因此,在第一小节中,在创建立方体贴图方面,为我们提供一些不同的技术。
1.1、准备工作
Unity 为我们提供了 JavaScript 代码,以便能够从我们创建的环境中创建立方体贴图。那么让我们来看看。以下是它的脚本参考链接:www.packtpub.com/support。这将作为我们脚本的基础。我们要将其转换为 C#。在本章的最后一个小节中,我们将介绍如何创建一个简单的系统来在多个位置生成立方体贴图,并且角色在环境中移动时,使用该数据在这些反射贴图之间进行交换,这会为我们提供一个半实时反射系统。
对于本小节,我们只了解立方体贴图的工作原理,这项准备可用于你的游戏的动态反射系统。
- 我们需要为场景创建一些元素,这些元素将充当反射立方体贴图中的灯光。因此,我们需要在场景中创建一些几何平面。你可以在建模软件(如 Maya 或 Max)中制作这些模型,也可以使用默认的 Unity 平面。无论哪种方式,这都无关紧要。你的场景只要类似于 图1.1 中展示的即可:

图1.1
- 现在,我们需要制作一些纹理,用来模拟不同类型光源的效果以及模拟环境中光线的衰减和强度。请参考以下 图1.2 中显示的效果:

图1.2
- 接下来,我们需要使用 Unity 提供给我们的内置着色器,以便我们可以将平面几何体和纹理用作立方体贴图中的光源。建议我们使用 unlit/transparent 着色器,用它可以来模拟灯光的纹理的完整强度。完成后,您的场景应类似于如下 图1.3 所示:

图1.3
1.2、如何实现...
让我们按照以下几个步骤开始编写着色器:
- 步骤1. 我们首先需要创建一个新脚本,但由于这将是一个弹出的编辑器窗口,因此我们必须将此脚本放在一个名为 Editor 的文件夹中。现在,在 Project 面板中创建该文件夹,然后在该文件夹中创建一个名为 GenerateStaticCubemap 的 C# 脚本,如下 图1.2 所示。创建后,双击新脚本启动它。

图1.2
-
步骤2. 打开脚本后,我们开始编辑脚本来实现我们需要的功能。首先,我们需要创建一个新的 using 指令,以便使用 UnityEditor 命名空间。
using UnityEngine; using UnityEditor; using System.Collections;
-
步骤3. 为了将此脚本视为弹出式编辑器窗口,我们需要使 GenerateStaticCubemap 脚本继承自 ScriptableWizard 类。这个类会为我们提供一些不错的低级函数,我们可以在脚本中使用它们。
public class GenerateStaticCubemap : ScriptableWizard {
-
步骤4 . 然后,我们需要添加一些公开的变量,以便我们可以存储新的 Cubemap 和 Cubemap 的位置。将以下代码添加到类的开始位置:
public Transform renderPosition; public Cubemap cubemap;
-
步骤5. 此脚本中的第一个函数是名为 OnWizardUpdate()的一个内置函数。首次打开 wizard 或更改 GUI 时,就会调用此函数。因此,这是一个很好的检查方式,并且还是一个用户向 wizard 提供一些资源的好地方。如果我们在变量中没有 Cubemap 或 transform,我们需要将 isValid 布尔值设置为 false,并且不允许 future 函数。
void OnWizardUpdate () { helpString = "Select transform to render" + "from and cubemap to render into"; if(renderPosition != null && cubemap != null) { isValid = true; } else { isValid = false; } }
-
步骤6. 如果 isValid 布尔值等于 true,则 wizard 将调用 OnWizardCreate() 函数。这会为我们创建一台新的摄像机;我们提供的 transform 用来作为它的位置信息,并使用 RenderToCubemap() 函数来返回 Cubemap。
void OnWizardCreate () { // 创建一个临时摄像机 GameObject go = new GameObject("CubeCam", typeof(Camra)); // 把它放在我们渲染的位置上 go.transform.position = renderPosition.position; go.transform.rotation = Quaternion.identity; // 渲染立方体贴图 go.camera.RenderToCubemap(cubemap); // 销毁临时摄像机 DestroyImmediate(go); }
-
步骤7. 最后,我们需要调用 wizard 以从 Unity 编辑器菜单中的菜单选项激活它。在 GenerateStaticCubemap 类中输入以下代码:
[MenuItem("CookBook/Render Cubemap")] static void RenderCubemap() { ScriptableWizard.DisplayWizard("Render CubeMap", typeof(GenerateStaticCubemap), "Render!"); }
-
完整代码:
using UnityEngine; using UnityEditor; using System.Collections; //using System.Collections.Generic; public class GenerateStaticCubemap : ScriptableWizard { public Transform renderPosition; public Cubemap cubemap; void OnWizardUpdate () { helpString = "Select transform to render" + "from and cubemap to render into"; if(renderPosition != null && cubemap != null) { isValid = true; } else { isValid = false; } } void OnWizardCreate () { // 创建一个临时摄像机 GameObject go = new GameObject("CubeCam", typeof(Camera)); // 把它放在我们渲染的位置上 go.transform.position = renderPosition.position; go.transform.rotation = Quaternion.identity; // 渲染立方体贴图 // go.camera.RenderToCubemap(cubemap); // API已被更换 go.GetComponent<Camera>().RenderToCubemap(cubemap); // 销毁临时摄像机 DestroyImmediate(go); } [MenuItem("CookBook/Render Cubemap")] static void RenderCubemap() { ScriptableWizard.DisplayWizard("Render CubeMap", typeof(GenerateStaticCubemap), "Render!"); } } /* using UnityEngine; using UnityEditor; using System.Collections; //using System.Collections.Generic; public class GenerateStaticCubemap : ScriptableWizard { public Transform renderPosition; public Cubemap cubemap; void OnWizardUpdate () { helpString = "Select transform to render" + "from and cubemap to render into"; if(renderPosition != null && cubemap != null) { isValid = true; } else { isValid = false; } } void OnWizardCreate () { // 创建一个临时摄像机 GameObject go = new GameObject("CubeCam", typeof(Camera)); // 把它放在我们渲染的位置上 go.transform.position = renderPosition.position; go.transform.rotation = Quaternion.identity; // 渲染立方体贴图 go.camera.RenderToCubemap(cubemap); // 销毁临时摄像机 DestroyImmediate(go); } [MenuItem("CookBook/Render Cubemap")] static void RenderCubemap() { ScriptableWizard.DisplayWizard("Render CubeMap", typeof(GenerateStaticCubemap), "Render!"); } } */
1.3、实现原理...
我们首先创建一个新脚本,并将其声明的类继承 ScriptableWizard 。它是用来告诉 Unity3D 我们打算为 Unity 制作一个弹出窗口类型的自定义编辑器。这就是为什么我们必须将此脚本放入名为 Editor 的文件夹中的原因了。否则,Unity 不会将其识别为自定义编辑器类型的脚本。
在下一步中声明的变量主要用来存储我们要创建的立方体贴图(Cubemap)的位置信息,并且这个方法也可以使用在 Project 选项卡中去储存一个新的 Cubemap GameObject 构造函数。有了这些变量我们就可以生成新的 Cubemap 了。
然后,我们就可以使用 OnWizardUpdate() 函数了,它是 ScriptableWizard 类提供给我们的函数。当 wizard 首次被打开时以及 wizard 的任何一个 GUI 元素发生更改时就会调用该函数。因此,我们可以使用它来验证使用者是否真的启动了 transform 和新的 Cubemap。如果有,我们将 isValid 变量设置为 true;如果没有,我们将 isValid 设置为 false。isValid 是一个内置变量,由 ScriptableWizard 类提供给我们。它的作用只是让你打开和关闭 wizard 底部的 Create 按钮。这可以禁止其他人去运行空位置函数或 Cubemap 。
一旦我们确保用户为我们提供了正确的数据来处理,我们就可以继续执行 OnWizardCreate() 函数了。这就是 Cubemap 创建的核心所在。首先,创建一个新的 GameObject 构造函数,并确保将其创建为 Camera 类型。然后,使用我们提供的位置信息来定位它。
此时,我们有一台新相机并对其进行了定位。需要做的就是调用 RenderToCubeMap() 函数,并将用户提供的 Cubemap 传递给它。一旦这个函数运行,Cubemap 的 6 张图像将被创建并组装到用户提供的 Cubemap 对象中。
最后,我们为 wizard 创建一个菜单选项,以便我们可以从 Unity 的顶部菜单栏中选择此工具。使用该菜单,我们就可以调用 wizard 的 Static 函数,该函数实际上就是用来显示菜单的。这样就完成了在 Unity 编辑器中直接创建一个生成立方体贴图的小工具。
1.4、另请参阅
让我们看一下其他生成 Cubemap 的应用程序。以下为你提供了一个不错的资源列表,你i可以使用这些资源来创建自己的 Reflection Pipeline 或 Workflow:
- HDR Light Studio Pro: HDR Light Studio - Make and edit image-based lighting in your 3D softwarehttps://www.lightmap.co.uk/https://www.lightmap.co.uk/https://www.lightmap.co.uk/https://www.lightmap.co.uk/https://www.lightmap.co.uk/https://www.lightmap.co.uk/https://www.lightmap.co.uk/
https://www.lightmap.co.uk/
- 附加参考链接:HDR Light Studio(3D渲染软件 )https://blog.csdn.net/m0_56734636/article/details/144347511?spm=1001.2014.3001.5502https://blog.csdn.net/m0_56734636/article/details/144347511?spm=1001.2014.3001.5502https://blog.csdn.net/m0_56734636/article/details/144347511?spm=1001.2014.3001.5502https://blog.csdn.net/m0_56734636/article/details/144347511?spm=1001.2014.3001.5502https://blog.csdn.net/m0_56734636/article/details/144347511?spm=1001.2014.3001.5502https://blog.csdn.net/m0_56734636/article/details/144347511?spm=1001.2014.3001.5502https://blog.csdn.net/m0_56734636/article/details/144347511?spm=1001.2014.3001.5502
https://blog.csdn.net/m0_56734636/article/details/144347511?spm=1001.2014.3001.5502
第2节. Unity3D 中实现简单的立方体贴图反射

现在我们已经知道了如何去创建自己的自定义立方体贴图,接下来我们可以看看如何使用这种新的纹理类型来模拟着色器中的反射。使用立方体贴图来实现反射的概念实际上是比较简单的,而且它为你的着色器效果提供了一个非常强大的工具。它的工作原理是使用模型表面每个顶点的法线来查找立方体贴图纹理上的位置。此查找将返回一个颜色值,该值模拟 Cubemap 在对象表面上反射的效果。这就是它的基础的概念。
这个特定的方法将迈出使用立方体贴图进行反射的第一步。Unity 实际上为我们提供了自动获取反射矢量的方法,因此我们不必自己计算。这是在 Input 结构体中使用内置的 worldRefl 向量完成的。这将有助于我们进行 Cubemap纹理的查找操作。因此,第一步为我们提供了给 Surface Shader 创建反射效果最基本的方法。
2.1、准备工作
在开始 Shader 代码之前,我们需要通过创建一些资源来布置一个简单的场景。
- 创建新场景、材质和着色器。确保为您的新资产指定一个易于识别的名称。
- 将着色器赋予到材质球上,然后将材质球赋予给对象。
- 最后,制作或找一些可用来测试的立方体贴图。
在下 图2.1 的屏幕截图中显示了我们在本节中使用的 Cubemap。你的立方体贴图可能和下 图2.1 展示的会有所不同,但我们只是想使用一个内容比较干净没有任何干扰的环境图。

图2.1
2.2、如何实现...
让我们按照以下几个步骤来开始编写 Shader 代码。
步骤1. 首先,我们在 Properties 块中创建一些新属性。我们需要一个地方来获取立方体贴图纹理并控制反射数量:
Properties
{
_MainTint("Diffuse Tint", color) = (1,1,1,1)
_MainTex("Main Map", 2D) = "white"{}
_Cubemap("CubeMap", CUBE) = ""{}
_ReflAmount("Reflection Amount", Range(0.01,1)) = 0.5
}
步骤2. 然后,我们需要在 SubShader 块中声明这些属性。这将允许我们从 Properties 块中访问到这些属性的数据。
// 将属性链接到CG程序
sampler2D _MainTex;
samplerCUBE _Cubemap;
half4 _MainTint;
float _ReflAmount;
步骤3. 为了从表面上模拟到正确反射角度,我们需要获得某种矢量数据,这些数据将为我们提供正确的世界反射方向。为此,我们可以使用 Unity 表面着色器的另一个内置功能。在 Input 结构体中,以下代码将为我们提供一个可以在 Shader 中使用的世界反射向量:
struct Input
{
float2 uv_MainTex;
float3 worldRefl;
};
步骤4. 最后,我们只需要使用 Input 结构体中的世界反射向量和 texCUBE() 函数,对Cubemap 贴图进行采样 的纹理信息。将以下代码添加到 surf() 函数中:
void surf (Input IN, inout SurfaceOutput o)
{
float4 c = tex2D (_MainTex, IN.uv_MainTex) * _MainTint;
o.Emission = texCUBE(_Cubemap, IN.worldRefl).rgb * _ReflAmount;
o.Albedo = c.rgb;
o.Alpha = c.a;
}
完整代码:
Shader "CookbookShaders/Cubemap reflection in Unity3D"
{
Properties
{
_MainTint("Diffuse Tint", color) = (1,1,1,1)
_MainTex("Main Map", 2D) = "white"{}
_Cubemap("CubeMap", CUBE) = ""{}
_ReflAmount("Reflection Amount", Range(0.01,1)) = 0.5
}
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 200
CGPROGRAM
#pragma surface surf Lambert
// 将属性链接到CG程序
sampler2D _MainTex;
samplerCUBE _Cubemap;
half4 _MainTint;
float _ReflAmount;
struct Input
{
float2 uv_MainTex;
float3 worldRefl;
};
void surf (Input IN, inout SurfaceOutput o)
{
float4 c = tex2D (_MainTex, IN.uv_MainTex) * _MainTint;
o.Emission = texCUBE(_Cubemap, IN.worldRefl).rgb * _ReflAmount;
o.Albedo = c.rgb;
o.Alpha = c.a;
}
ENDCG
}
FallBack "Diffuse"
}
下 图2.2 显示了我们自己创建的自定义 Cubemap 的结果:

图2.2
2.3、实现原理...
如果 Shader 没有报错,你应该会看到 Cubemap 被反射到了物体对象上,这样它就能像真实的反射物体一样对立方体贴图进行采样。这一切都是可以实现的,因为 Unity3D 在表面着色器的 Input 结构体中内置了属性。worldRefl 属性为我们提供了正确采样立方体贴图所需的反射向量。只需在 texCube() 函数中使用 worldRefl 属性即可,我们就可以轻松地为立方体贴图采样正确的反射视角。
如下 图2.3 显示了反射数据的示例:
下 图2.3 显示了一个 Shader 中的反射数据的例子,但看起来像一个调试脚本:

图2.3
2.4、另请参阅
第3节. Unity3D 中实现遮罩反射

拥有反射是非常赞的,但我们并不是所有的地方都需要反射。几乎所有东西都会反射一定量的环境,因此,我们需要对反射效果进行某种逐像素控制。
在本小节中,我们会介绍一种技术,该技术允许我们使用纹理作为蒙版来驱动反射量。总的来说,我们可以使用纹理的灰度值来表示表面的反射程度,这意味着纹理中的黑色值表示物体表面完全没有反射,而白色值则表示物体的表面完全反射。在当今的游戏制作流程中都可以看到艺术家可以灵活的控制镜面反射的反射量。那么,让我们来看看如何在 Unity 中使用表面着色器来做到这一点。
3.1、准备工作
首先让我们给遮罩反射着色器准备一个新的场景。
-
- 我们需要为着色器准备好一个立方体贴图,您可以新生成一个,也可以只使用上一小节中的立方体贴图。本小节使用的 Cubemap 在本书的代码示例中,如 图3.1 所示:

图3.1
-
- 我们还需要一张纹理,用于表示物体的表面哪些区域有反射效果,哪些区域没有反射效果。请记住,下边纹理中的黑色区域表示完全没有射率(0%反射率),白色表示完全反射率(100%反射率),而介于两者之间的所有灰度值是提供了一定量的反射率(0%<反射率<100%)。下 图3.2 展示我们在本小节中使用的纹理:

图3.2
-
- 最后,创建一个新场景,里面放置一个物体对象、地面和平行光,方便我们去观察着色器的反射效果!
3.2、如何实现...
设置好场景后,我们现在可以开始编写反射效果所需的代码了。
-
步骤1. 将以下属性添加到着色器中。这会让我们把自己的自定义立方体贴图和反射遮罩赋予给我们的着色器:
Properties
{
_MainTint("Diffuse Tint", color) = (1,1,1,1)
_MainTex("Main Map", 2D) = "white"{}
_ReflAmount("Reflection Amount", Range(0.01,1)) = 0.5
_Cubemap("CubeMap", CUBE) = ""{}
_ReflMask("Reflection Mask", 2D) = "white"{}
} -
步骤2. 然后,我们需要在 SubShader 块中声明这些属性。这将允许我们从 Properties 块中访问到这些属性的数据。
// 将属性链接到CG程序
sampler2D _MainTex;
sampler2D _ReflMask;
samplerCUBE _Cubemap;
half4 _MainTint;
float _ReflAmount; -
步骤3. 为了让我们正确地模拟Cubemap的反射,我们需要在 Input 结构体中声明 worldRefl 属性。我们可以使用此数据查找 texCUBE() 函数中的参数。
struct Input
{
float2 uv_MainTex;
float3 worldRefl;
}; -
步骤4. 最后,我们在 surf() 函数中增加以下代码。将在下一个知识点中来解释它们的含义:
void surf (Input IN, inout SurfaceOutput o)
{
float4 c = tex2D (_MainTex, IN.uv_MainTex);
float3 reflection = texCUBE(_Cubemap, IN.worldRefl).rgb;
float4 reflMask = tex2D(_ReflMask, IN.uv_MainTex);o.Albedo = c.rgb * _MainTint; o.Emission = (reflection * reflMask.r) * _ReflAmount; o.Alpha = c.a;
}
-
完整代码:
Shader "CookbookShaders/Mask reflection in Unity3D"
{
Properties
{
_MainTint("Diffuse Tint", color) = (1,1,1,1)
_MainTex("Main Map", 2D) = "white"{}
_ReflAmount("Reflection Amount", Range(0.01,1)) = 0.5
_Cubemap("CubeMap", CUBE) = ""{}
_ReflMask("Reflection Mask", 2D) = "white"{}
}
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 200
CGPROGRAM
#pragma surface surf Lambert// 将属性链接到CG程序 sampler2D _MainTex; sampler2D _ReflMask; samplerCUBE _Cubemap; half4 _MainTint; float _ReflAmount; struct Input { float2 uv_MainTex; float3 worldRefl; }; void surf (Input IN, inout SurfaceOutput o) { float4 c = tex2D (_MainTex, IN.uv_MainTex); float3 reflection = texCUBE(_Cubemap, IN.worldRefl).rgb; float4 reflMask = tex2D(_ReflMask, IN.uv_MainTex); o.Albedo = c.rgb * _MainTint; o.Emission = (reflection * reflMask.r) * _ReflAmount; o.Alpha = c.a; } ENDCG }
FallBack "Diffuse"
}
下 图3.3 显示了在 Unity3D Surface Shader 中使用了反射纹理遮罩的渲染结果:

图3.3
3.3、实现原理...
此 Shader 的工作原理非常简单,只需首先使用 texCUBE() 函数对 Cubemap 纹理进行采样即可。此函数内置于 CGFX 语言中。它为我们提供了采样的立方体贴图颜色,然后我们可以将其应用于着色器的表面。Unity 通过在 Input 结构中为我们提供 worldRefl 属性来帮助我们完成这项工作。如在上一小节中所述,此属性从摄像机视角为我们提供反射向量。
获得反射元素后,我们需要对反射遮罩纹理进行采样。这可以直接使用 tex2D() 内置函数来完成,在之前的第 2 章时我们就已经看到了使用纹理实现的此效果。
将两种纹理类型采样一起存储到 surf() 函数中的一个变量中,我们只需将立方体贴图颜色与反射纹理颜色相乘,并将其传递到表面的 Output 结构体的 o.Emission 参数中。最后,为了全局控制整体反射强度,我们将反射遮罩的结果乘以 _ReflectionAmount 属性。通过_ReflectionAmount 属性我们就可以控制整个表面的反射总量。
以下截图显示了使用 _ReflectionAmount 属性控制整体反射不同的渲染结果,如 图3.4 所示:

图3.4
第4节. Unity3D 中实现法线贴图和反射

在某些情况下,我们想让法线贴图可以影响到被反射的立方体贴图。假如你想制作磨砂玻璃的表面或冰块表面效果时。我们无法把物体表面的所有细节用建模的方式表现出来,而且我们需要在游戏中要以 60 fps 的速度运行。所以使用法线贴图是最好的选择,它可以模拟更高分辨率细节的效果,因此我们需要学习如何将法线贴图信息传递给反射效果。
为了完成此任务,我们需要用到 Input 结构体的另一个内置参数,该参数将传入由法线映射技术生成的修改后的表面法线。那么,让我们看看如何修改 Input 结构体来生成这种效果。
4.1、准备工作
让我们按照以下几个步骤创建一个新的场景。
-
- 同样,我们需要一个立方体贴图来制作反射效果。因此,你可以去使用之前小节中的 Cubemap,也可以去新生成一个 Cubemap。在本小节中,我们使用的 Cubemap 在本书的示例代码中可以找到,如下 图4.1 所示:

图4.1
-
- 我们还需要一张法线贴图来生成法线贴图的反射。如下 图4.2 所示:

图4.2
-
- 最后,创建一个新的场景,里面包括物体对象、地面和平行光,并创建一个新的 Shader 和材质球。这会方便我们来查看 Shader的效果以及验证它是否有报错。
4.2、如何实现...
现在,让我们开始编写 Shader 代码,来学习如何向 Shader 中添加反射法线。
-
步骤1. 让我们添加所需的属性,以便我们能够在 Shader 中计算自定义的立方体贴图和法线贴图。这个步骤我们现在已经非常熟悉了。将以下代码添加到新建 Shader 中的 Properties 块中:
Properties
{
_MainTint("Diffuse Tint", color) = (1,1,1,1)
_MainTex("Main Map", 2D) = "white"{}
_NormalMap("Normal Map", 2D) = "bump"{}
_Cubemap("CubeMap", CUBE) = ""{}
_ReflAmount("Reflection Amount", Range(0.01,1)) = 0.5
} -
步骤2. 然后,我们需要在 SubShader 块中声明这些属性,这样我们就可以访问 Properties 块中的属性数据。
// 将属性链接到CG程序
samplerCUBE _Cubemap;
sampler2D _MainTex;
sampler2D _NormalMap;
half4 _MainTint;
float _ReflAmount; -
步骤3. 接下来,在 Input 结构体中添加以下代码。这就是法线贴图反射的真正魔力所在。通过使用 INTERNAL_DATA 语句,修改之后的法线贴图就可以传输到表面法线:
struct Input
{
float2 uv_MainTex;
float2 uv_NormalMap;
float3 worldRefl;
INTERNAL_DATA
}; -
步骤4. 最后,我们在 surf() 函数中添加以下代码,来获得法线贴图反射:
void surf (Input IN, inout SurfaceOutput o)
{
half4 c = tex2D (_MainTex, IN.uv_MainTex);
float3 normals = UnpackNormal(tex2D(_NormalMap, IN.uv_NormalMap)).rgb;o.Albedo = c.rgb * _MainTint; o.Normal = normals; o.Emission = texCUBE(_Cubemap, WorldReflectionVector(IN, o.Normal)).rgb * _ReflAmount; o.Alpha = c.a;
}
-
完整代码:
Shader "CookbookShaders/4-4 Normal maps and reflections in Unity3D"
{
Properties
{
_MainTint("Diffuse Tint", color) = (1,1,1,1)
_MainTex("Main Map", 2D) = "white"{}
_NormalMap("Normal Map", 2D) = "bump"{}
_Cubemap("CubeMap", CUBE) = ""{}
_ReflAmount("Reflection Amount", Range(0.01,1)) = 0.5
}
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 200
CGPROGRAM
#pragma surface surf Lambert// 将属性链接到CG程序 samplerCUBE _Cubemap; sampler2D _MainTex; sampler2D _NormalMap; half4 _MainTint; float _ReflAmount; struct Input { float2 uv_MainTex; float2 uv_NormalMap; float3 worldRefl; INTERNAL_DATA }; void surf (Input IN, inout SurfaceOutput o) { half4 c = tex2D (_MainTex, IN.uv_MainTex); float3 normals = UnpackNormal(tex2D(_NormalMap, IN.uv_NormalMap)).rgb; o.Albedo = c.rgb * _MainTint; o.Normal = normals; o.Emission = texCUBE(_Cubemap, WorldReflectionVector(IN, o.Normal)).rgb * _ReflAmount; o.Alpha = c.a; } ENDCG }
FallBack "Diffuse"
}
以下截图显示了使用法线贴图影响了反射效果的渲染结果,如 图4.2 所示:

图4.2
4.3、实现原理...
你应该注意到了,这个 Shader 看起来与上一个 Shader 非常相似,但是它们有一个非常重要的区别。我们想要使用逐像素法线贴图的方式来影响反射的立方体贴图。为此,你必须计算出物体的表面法线之后,法线贴图才可以被应用到着色器上。所以在计算完成法线贴图之后,我们需要添加一段代码:
float3 normals = UnpackNormal(tex2D(_NormalMap, IN.uv_NormalMap)).rgb;
o.Normal = normals;
一旦计算完 Shader 中的这些代码行后,模型表面的法线就会被修改成我们想要的样子了;接下来,我们需要使用它来扰乱我们的反射。我们可以通过在 Input 结构体中声明 INTERNAL_DATA,然后使用 WorldReflectionVector(IN, o.Normal) 作为立方体贴图的查找信息来访问这个修改后的法线。这是 Unity 为我们提供的另一个内置功能,所以我们不必自己再费力的编码。从而可以专注于编写 Shader 中我们想要的效果。
4.5、更多内容
在我们的 Input 结构体中,还可以访问更多的其他内置函数,我们在以后的章节中也一定会使用它们;下表描述了这些内置函数中的每一个的作用以及如何使用它们:
|-----------------------------------|-------------------------------------------------------------------------------------------------------------------------------|
| | |
| float3 viewDir | 表示视角方向,用于计算视差效果、边缘照明等。 |
| float4 COLOR | 表示逐顶点插值的颜色。 |
| float4 screenPos | 表示反射屏幕空间的位置。例如,在 Dark Unity 中的 WetStreet 着色器使用。 |
| float3 worldPos | 表示世界空间位置。 |
| float3 worldRefl | 如果 Surface Shader 不写入 o.Normal,则将包含世界反射向量。有关示例,请参见 Reflect-Diffuse 着色器。 |
| float3 worldRef; INTERNAL_DATA | 如果 Surface Shader 不写入 o.Normal,则将包含世界法线向量。 |
| float3 worldNormal; INTERNAL_DATA | 如果 Surface Shader 写入 o.Normal,将包含世界反射向量。要获取基于每像素法线贴图的反射向量,请使用 WorldReflectionVector (IN, o.Normal)。例如,请参见 Reflect-Bumped 着色器。 |
您也可以转到以下链接来获取有关这些内置函数的更多信息:
第5节. Unity3D 中实现菲涅耳反射

菲涅耳反射是最常用的反射类型之一。这基本上会增加你看到对象表面的反射量。你几乎会在任何类型的表面中看到这一点,但这种效果最常用的表面之一是汽车的车身。我们可以看到表面是反光的,但随着车身表面越来越倾向于你的视线,您会注意到反射和镜面反射变得更加强烈,并产生漂亮的边缘光类型效果。
但是,并非所有表面都具有相同数量的菲涅耳反射。某些表面(如车身表面)具有高强度的菲涅耳反射,而像塑料片这样的表面具有更暗淡的菲涅耳反射强度。
本小节将为你提供菲涅耳反射的基础实现方式,因为在现实世界中,菲涅耳反射是相对于观察者对对象表面视角的反射和折射的计算。但是,由于我们没有介绍任何类型的折射技术,让我们来看看大多数游戏作品都实现了什么,以及如何对其进行修改以创建非常吸引人的视觉反射效果。
5.1、准备工作
同样,我们仍然需要创建一个新场景同时在场景中创建一些资产,以便我们专注于正在编写的 Shader。
-
- 我们需要一个立方体贴图来产生我们的菲涅耳效果。因此,我们可以生成一个新的,也可以使用之前的立方体贴图。以下屏幕截图显示的是我们本小节中使用的立方体贴图,它包含在了本书的支持页面 www.packtpub.com/support 网址中。
图5.1
-
- 创建一个新的场景、一个物体对象(球体或者立方体皆可)、地面、着色器和新的材质球。
-
- 最后,创建一个平行光,这样可以提供场景中需要的一些光源信息。
5.2、如何实现...
现在,让我们编写 Shader 代码并让 Fresnel 效果正常工作。
-
步骤1. 首先,我们需要在 Properties 块中设置我们的属性。这次我们将使用内置的 BlinnPhong 光照模型,因此我们需要声明一些属性才能使用光照模型的 Specular 组件。
Properties
{
_MainTint("Diffuse Tint", color) = (1,1,1,1)
_MainTex("Main Map", 2D) = "white"{}
_Cubemap("CubeMap", CUBE) = ""{}
_ReflAmount("Reflection Amount", Range(0.01,1)) = 0.5
_RimPower("Fresnel Falloff", Range(0.1, 3)) = 2
_SpecColor("Specular Color", color) = (1,1,1,1)
_SpecPower("Specular Power", Range(0, 1)) = 0.5
} -
步骤2. 对于这个 Shader,我们需要使用 Shader 3.0模式,以便我们有足够的寄存器将所有数据导入到 surf() 函数中。因此,我们需要将 #pragma 语句添加到 SubShader 块中的定义中。
CGPROGRAM
#pragma surface surf BlinnPhong
#pragma target 3.0 -
步骤3. 然后,我们需要确保在新属性和 SubShader 块之间创建连接,因此我们需要按如下方式声明变量:
// 将属性链接到CG程序
samplerCUBE _Cubemap;
sampler2D _MainTex;
half4 _MainTint;
float _ReflAmount;
float _RimPower;
float _SpecPower; -
步骤4. 要使反射正常工作,我们需要在 Input 结构体中声明 worldRefl 参数以及 viewDir 参数
struct Input
{
float2 uv_MainTex;
float3 worldRefl;
float3 viewDir;
}; -
步骤5. 然后,我们需要在 surf() 函数中计算边缘效果,用它来去创建简单的菲涅耳反射效果。
void surf (Input IN, inout SurfaceOutput o)
{
half4 c = tex2D (_MainTex, IN.uv_MainTex);float rim = 1.0 - saturate(dot(o.Normal, normalize(IN.viewDir))); rim = pow(rim, _RimPower); o.Albedo = c.rgb * _MainTint; o.Emission = (texCUBE(_Cubemap, IN.worldRefl).rgb * _ReflAmount) * rim; o.Specular = _SpecPower; o.Gloss = 1.0; o.Alpha = c.a;
}
-
完整代码:
Shader "CookbookShaders/4-5 Fresnel reflections in Unity3D"
{
Properties
{
_MainTint("Diffuse Tint", color) = (1,1,1,1)
_MainTex("Main Map", 2D) = "white"{}
_Cubemap("CubeMap", CUBE) = ""{}
_ReflAmount("Reflection Amount", Range(0.01,1)) = 0.5
_RimPower("Fresnel Falloff", Range(0.1, 3)) = 2
_SpecColor("Specular Color", color) = (1,1,1,1)
_SpecPower("Specular Power", Range(0, 1)) = 0.5
}
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 200
CGPROGRAM
#pragma surface surf BlinnPhong
#pragma target 3.0// 将属性链接到CG程序 samplerCUBE _Cubemap; sampler2D _MainTex; half4 _MainTint; float _ReflAmount; float _RimPower; float _SpecPower; struct Input { float2 uv_MainTex; float3 worldRefl; float3 viewDir; }; void surf (Input IN, inout SurfaceOutput o) { half4 c = tex2D (_MainTex, IN.uv_MainTex); float rim = 1.0 - saturate(dot(o.Normal, normalize(IN.viewDir))); rim = pow(rim, _RimPower); o.Albedo = c.rgb * _MainTint; o.Emission = (texCUBE(_Cubemap, IN.worldRefl).rgb * _ReflAmount) * rim; o.Specular = _SpecPower; o.Gloss = 1.0; o.Alpha = c.a; } ENDCG }
FallBack "Diffuse"
}
以下屏幕截图演示了简单 Frensel 效果着色器的最终结果。它也可以简单的展示为汽车的着色器的基础,如图5.2:

图5.2
5.3、实现原理...
在此示例中,我们只是创建了一个衰减值,可当做遮罩来遮蔽表面较高和较低的反射率。通过使用视方向到表面法线的比较,我们可以计算面向摄像机的衰减值。然后,我们反转该值以获得在表面边缘更白的蒙版,当表面越面向观察者时会越黑。请参下图5.3以供参考:

图5.3
然后,我们通过添加 Specular 值和 Diffuse 值来完成着色器,以实现最终的菲涅耳反射着色器。
第6节. 在 Unity3D 中创建简单的动态立方体贴图系统

到目前为止,我们已经了解到了很多有用的信息,但是当物体在环境中移动时,我们的反射并不能真正的反射出真实的世界。例如,如果你有一个由多个房间和走廊组成的环境,我们无法为整个关卡烘焙一个立方体贴图,并将其放在单个立方体贴图中。这并不能反射出房间之间合理的环境。我们会得到一个非常静态、无趣的反射。
有几种方法可以解决这个问题,使一个房间的反射与第二个房间的反射不同。第一种也是最基本的方法是根据 Room 中的位置交换 Cubemap。因此,当您从一个房间移动到另一个房间时,立方体贴图将替换为该房间正确的立方体贴图。第二种方法是角色在环境中移动时,实时更新 Cubemap,最终在游戏进行的每一帧中都会获得一个新的 Cubemap。虽然第二个选项听起来在视觉上更吸引人,因为你会看到立方体贴图之间出现一个短暂切换的一个动作,但它相当昂贵,非常占资源,因此需要与你的游戏所需所有其他资源进行权衡。
在本小节中我们介绍第一个选项,并向你展示如何设置一个非常简单的系统,以便根据环境中基于位置信息来切换两个立方体贴图。本小节的最后一部分提供了有关创建实时反射系统的更多信息,因此,如果你有兴趣并想了解这两种技术之间的区别,那么就可以开始了!
6.1、准备工作
-
- 我们需要创建一个新场景,并在世界中放置一个地平面和一个球体。此外,添加一个定向光源,为我们的着色器获取一些光照。
-
- 继续向场景添加两个空的 GameObject 构造函数,并分别将它们命名为 pos001 和 pos002。
-
- 然后,让我们为球体赋予一个新的材质球,并将我们刚刚在上一小节中创建的菲涅尔着色器赋予到我们新材质球上。你的场景现在应类似于如 图6.1 。
-
- 最后,让我们创建一个脚本并将其命名为 SwapCubemaps.cs。
以下屏幕截图显示了我们准备好的场景的结果,该场景已准备好用于我们的动态反射系统:

图6.1
6.2、如何实现...
场景准备就绪后,我们可以按照接下来的几个步骤开始编写反射系统代码。
-
步骤1. 让我们在声明类之前添加 [ExecuteInEditMode]。
[ExecuteInEditMode]
public class SwapCubemaps : MonoBehaviour
{ -
步骤2. 然后,我们需要声明一些变量来存储我们系统中的所有数据。我们将在本小节中的下一部分解释这些。
public Cubemap cubeA;
public Cubemap cubeB;public Transform posA;
public Transform posB;private Material curMat;
private Cubemap curCube; -
步骤3. 为了直观地看到立方体贴图位置在空间中的位置,我们需要利用 Unity3D 为我们提供的超棒的 Gizmos 功能。因此,让我们将以下代码添加到脚本的底部:
void OnDrawGizmos()
{
Gizmos.color = Color.green;if(posA) { Gizmos.DrawWireSphere(posA.position, 0.5f); } if(posB) { Gizmos.DrawWireSphere(posB.position, 0.5f); }
}
-
步骤4. 现在,我们需要创建一个新函数,该函数将根据我们设置的每个位置之间的距离来确定我们应该使用哪个立方体贴图:
private Cubemap CheckProbeDistance()
{
float distA = Vector3.Distance(transform.position, posA.position);
float distB = Vector3.Distance(transform.position, posB.position);if(distA < distB) { return cubeA; } else if(distB < distA) { return cubeB; } else { return cubeA; }
}
-
步骤5. 最后,我们只需要检查每一帧,看看环境中每个位置之间的距离是多少,并在我们的材质中合适的时候进行交换立方体贴图:
// Update is called once per frame
void Update()
{
//curMat = renderer.sharedMaterial;
curMat = GetComponent<Renderer>().sharedMaterial;if(curMat) { curCube = CheckProbeDistance(); curMat.SetTexture("_Cubemap", curCube); }
}
-
完整代码:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;[ExecuteInEditMode]
public class SwapCubemaps : MonoBehaviour
{
public Cubemap cubeA;
public Cubemap cubeB;public Transform posA; public Transform posB; private Material curMat; private Cubemap curCube; void OnDrawGizmos() { Gizmos.color = Color.green; if(posA) { Gizmos.DrawWireSphere(posA.position, 0.5f); } if(posB) { Gizmos.DrawWireSphere(posB.position, 0.5f); } } private Cubemap CheckProbeDistance() { float distA = Vector3.Distance(transform.position, posA.position); float distB = Vector3.Distance(transform.position, posB.position); if(distA < distB) { return cubeA; } else if(distB < distA) { return cubeB; } else { return cubeA; } } // Start is called before the first frame update void Start() { } // Update is called once per frame void Update() { //curMat = renderer.sharedMaterial; curMat = GetComponent<Renderer>().sharedMaterial; if(curMat) { curCube = CheckProbeDistance(); curMat.SetTexture("_Cubemap", curCube); } }
}
保存着色器后,返回到 Unity 编辑器中等待着色器编译好后。点击 Play 并来回移动球体。您应该会看到类似于下 图6.2 的结果:

图6.2
6.3、实现原理...
我们只需通过声明类的 [ExecuteInEditMode] 属性来启动此脚本。这会告诉 Unity,我们希望在编辑器中运行脚本来切换立方体贴图,而不仅仅是在点击 Play 时运行。这将允许我们进行测试立方体贴图的切换,而无需点击 Play --- 这样会将工作流程变得更快捷。
然后,该脚本包含一些变量,我们使用这些变量允许使用者输入两个立方体贴图和两个位置信息,我们使用它们来比较距离。最后,我们有两个私有变量,当程序运行时,我们可以使用它们来跟踪当前材质球和立方体贴图。
有了变量,我们就可以使用 OnDrawGizmos() 内置函数来实际显示我们让用户输入的转换位置的信息。这些位置将命令脚本何时切换我们的立方体贴图。
然后我们来了解这个程序的真正内容。我们声明自己的函数/方法,该函数/方法将使用 Vector3.Distance() 计算球体与两个变换中任何一个的距离。然后,它会检查哪个距离更小,并返回该位置的 Cubemap。
最后,在 Update() 函数中,我们从球体获取当前的材质球,或者此脚本附加到的对象,然后简单地分配从自定义函数返回的当前选定的立方体贴图。
这只是一个非常简单的脚本来阐述这个概念,但它可以扩展成一个完整的系统,每个房间都有多个立方体贴图。该系统可以在运行时为我们自动生成所有的立方体贴图,这对于无法负担完全实时反射系统的游戏来说非常有用。
6.4、另请参阅
您还可以尝试创建实时反射系统,其中 Cubemap 会针对游戏中的每一帧进行更新。这绝对是一个视觉上更吸引人的系统,但确实会以性能为代价:

这本书可以让你学习到如何使用着色器和屏幕特效让你的Unity工程拥有震撼的渲染画面。
作者:Kenny Lammers
