转载自 冯乐乐的 《Unity Shader入门精要》
立方体纹理
在图形学中,立方体纹理是环境映射的一种实现方法。环境映射可以模拟物体周围的环境,而使用了环境映射的物体可以看起来像镀了层金属一样反射出周围的环境。
和之前见到的纹理不同,立方体纹理一共包含了6张图像,这些图像对应了一个立方体的6个面,立方体纹理的名称也由此而来。立方体的每个面表示沿着世界空间下的轴向观察所得的图像。和之前使用二维纹理坐标不同,对立方体纹理采样我们需要提供一个三维的纹理坐标,这个三维纹理坐标表示了我们在世界空间下的一个3D方向。这个方向矢量从立方体的中心出发,当它向外部延伸时就会和立方体的6个纹理之一相交,而采样得到的结果就是由该焦点计算而来的。下图给出了使用方向矢量对立方体纹理采样的过程。
使用立方体纹理的好处在于,它的实现简单快速,而且得到的效果也比较好。但它有一些缺点,例如当场景中引入了新的物体,光源,或者物体发生移动时,我们就需要重新生成立方体纹理。除此以外,立方体纹理也仅可以反射环境,但不能反射使用了该立方体纹理的物体本身。这是因为,立方体纹理不能模拟多次反射的结果,例如两个金属球互相反射的情况(事实上,Unity5 引入的全局光照系统允许实现这一的自反射效果)。由于这样的原因,想要得到令人信服的渲染结果,我们应该尽量对凸面体而不要对凹面体使用立方体纹理(因为凹面体会反射自身)。
立方体纹理在实时渲染中有很多应用,最常见的是用于天空盒子以及环境映射。
天空盒子是游戏中用于模拟背景的一种方法。天空盒子这个名字包含了两个信息:它是用于模拟天空的,它是一个盒子。当我们在场景中使用了天空盒子时,整个场景就被包围在一个立方体内。这个立方体的每个面使用的技术就是立方体纹理映射技术。
在Unity中,想要使用天空盒子非常简单。我们只需要创建一个Skybox材质,再把它赋给该场景的相关设置即可。
我们首先来看如何创建一个Skybox材质。
1)新建一个材质
2)在该材质的Unity Shader 下拉菜单中选择Skybox/6 Sided,该材质需要6张纹理
3)将6张纹理赋给材质上
上述步骤得到的材质如下图所示。
上面的材质中,除了6张纹理属性外还有3个属性:Tint Color,用于控制该材质的整体颜色;Exposure,用于调整天空盒子的亮度;Rotation,用于调整天空盒子沿+y轴方向的旋转角度。
下面,我们来看一下如何为场景添加Skybox。
1)新建一个场景
2)在Window -> Lighting 菜单中,把之前的材质赋给Skybox选项。如下图所示。
为了让摄像机正常显示天空盒子,我们还需要保证渲染场景的摄像机的Camera组件的Clear Falgs 被设置为Skybox。这样,我们得到场景如下图所示。
需要说明的是,在Window -> Lighting ->Skybox 中设置的天空盒子会应用于该场景中的所有摄像机。如果我们希望某些摄像机可以使用不同的天空盒子,可以通过向该摄像机添加Skybox组件来覆盖掉之前的设置。也就是说,我们可以在摄像机上单击Component ->Rendering -> Skybox 来完成对场景默认天空盒子的覆盖。
在Unity中,天空盒子是在所有不透明物体之后渲染的,而其背后使用的网格是一个立方体或一个细分后的球体。
除了天空盒子,立方体纹理最常见的用处是用于环境映射。通过这种方法,我们可以模拟出金属质感的材质。
在Unity5中,创建用于环境映射的立方体纹理的方法有三种:第一种方法是直接由一些特殊布局的纹理创建;第二种方法是手动创建一个Cubemap资源,再把6张图赋给它;第三种方法是由脚本生成。
如果使用第一种方法,我们需要提供一张具有特殊布局的纹理,例如类似立方体展开图的交叉布局、全景布局等。然后,我们只需要把该纹理的Texture Type 设置为Cubemap即可,Uniry 会为我们做好剩下的事情。在基于物理的渲染中,我们通常会使用一张HDR图像来生成高质量的Cubemap。
第二种方法是 Unity5之前的版本中使用的方法。我们首先需要在项目资源中创建一个Cubemap,然后把6张纹理拖拽到她的面板中。在Unity5中,官方推荐使用第一种方法创建立方体纹理,这是因为第一种方法可以对纹理数据进行压缩,而且可以支持边缘修正、光滑反射和HDR等功能。
前两种方法都需要我们提前准备好立方体纹理的图像,它们得到的立方体纹理往往是被场景中的物体所公用的。但在理想情况下,我们希望根据物体在场景中位置的不同,生成它们各自不同的立方体纹理。这时,我们就可以在Unity中使用脚本来创建。这时通过利用Unity提供的Camera.RenderToCubemap函数来实现的。Camera.RenderToCubemap函数可以把从任意位置观察到的场景图像存储到6张图像中,从而创建出该位置上对应的立方体纹理。
在Unity 的脚本手册给出了如何使用Camera.RenderToCubemap函数来创建立方体纹理的代码。其中关键代码如下:
void OnWizardCreate(){
GameObject go = new GameObject("CubemapCamera");
go.AddComponent<Camera>();
go.transform.position = renderFromPosition.position;
go.GetComponent<Camera>().RenderToCubemap(cubemap);
DestroyImmediate(go);
}
在上面的代码中,我们在renderFromPisition 位置处动态创建一个摄像机,并调用Camera.RenderToCubemap函数把当前位置观察到的 图像渲染到用于指定的立方体纹理cubemap中,完成后再销毁临时摄像机。由于该代码需要添加菜单栏条目,因此我们需要把它放在Editor 文件夹下才能正确执行。
当准备好上述代码后,要创建一个Cubemap非常简单。
1)我们创建一个空的GameObject对象。我们会使用这个GameObejct的位置信息来渲染立方体纹理
2)新建一个用于存储的立方体纹理(在Project 视图下单击右键,选择Create -> Legacy -> Cubemap 来创建)。为了让脚本可以顺利将图像渲染到该立方体纹理中,我们需要在它的面板中勾选Readable选项。
3)从Unity菜单栏选择GameObject->Render into Cubemap,打开我们在脚本中实现的用于渲染立方体纹理的窗口,并把第1步中创建的GameObject和第2步中创建的Bubemap_0分别拖拽到窗口中的Render From Position和Cubemap选项,如下图所示。
4)单击窗口中的Render!按钮,就可以把从该位置观察到的世界空间下的6张图像渲染到Cubemap_0中,如下图所示。
需要注意的是,我们需要为Cubemap设置大小,即上图中的Face size选项。Face size值越大,渲染出来的立方体纹理分辨率越大,效果可能更好,但需要占用的内存也越大,这可以由面板最下方显示的内存大小得到。
准备好了需要的立方体纹理后,我们就可以对物体使用环境映射技术。而环境映射最常见的应用就是反射和折射。
使用了反射效果的物体通常看起来就像镀了层金属。想要模拟反射效果很简单,我们只需要通过入射光线的方向和表面法线方向来计算反射方向,再利用反射方向对立方体纹理采样即可。我们可以得到类似下图的结果。
我们需要做如下准备工作。
1)新建一个场景。我们替换掉Unity默认的天空盒子,把之前创建的天空盒子材质拖拽到Window -> Lighting -> Skybox 选项中。
2)向场景拖拽一个Teapot模型,并调整它的位置。
3)新建一个材质,把材质赋给Teapot模型
4)新建一个Shader,赋给材质,代码如下
Shader "Unity Shaders Book/Chapter10-Reflection"{
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"{}
}
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;
fixed _ReflectAmount;
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 = mul(UNITY_MATRIX_MVP,v.vertex);
o.worldNormal = WorldObjectToWorldNormal(v.normal);
o.worldPos = mul(_Object2World,v.vertex).xyz;
o.worldViewDir = UnityWorldSpaceViewDir(o.worldPos);
//计算了该顶点处的反射方向
o.worldRefl = reflect(-o.worldViewDir,o.worldNormal);
}
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));
//对立方体纹理的采样需要使用texCUBE函数
fixed3 reflection = texCUBE(_Cubemap,i.worldRefl).rgb*_ReflectColor;
UNITY_LIGHT_ATTENUATION(atten,i,i.worldPos);
//使用_ReflectAmount 来混合漫反射颜色和反射颜色,并和环境光照相加后返回。
fixed3 color = ambient + lerp(diffuse,reflection,_ReflectAmount)*atten;
return fixed4(color,1.0);
}
ENDCG
}
}
Fallback "Reflective/VertexLit"
}
折射的物理原理比反射复杂一些。定义:当光线从一种介质斜射入另一种介质时,传播方向一般会发生改变。当给定入射角时,我们可以使用斯涅耳定律来计算反射角。当光从介质1沿着河表面法线夹角为θ(1)的方向斜射入介质2时,我们可以使用如下公式计算折射光线与法线的夹角θ(2):
其中η(1)和η(2) 分别是两个介质的折射率。折射率是一项重要的物理常数,例如真空的折射率是1,而玻璃的折射率一般是1.5.下图给出了这些变量之间的关系。
通常来说,当得到折射方向后我们就会直接使用它来对立方体纹理进行采样,但这是不符合物理规律的。对一个透明物体来说,一种更准确的模拟方法需要计算两次折射——一次是当光线进入它的内部时,而另一次则是从它内部射出时。但是,想要阿紫实时渲染中模拟出第二次折射方向是比较复杂的,而且仅仅一次模拟得到的效果从视觉上看起来“也挺像那么回事的”。正如我们之前提到的——图形学第一准则“如果它看起来是对的,那么它就是对的”。因此,在实时渲染中我们通常仅模拟第一次折射。
我们得到的效果如下图所示:
我们添加一个Shader实现上述效果。
Shader "Unity Shaders Book/Chapter 10/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 = mul(UNITY_MATRIX_MVP, v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(_Object2World, 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));
// 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);
}
ENDCG
}
}
FallBack "Reflective/VertexLit"
}
在实时渲染中,我们经常会使用菲涅尔反射来根据视角方向控制反射程度。通俗地讲,菲涅尔反射描述了一种光学现象,即当光线照射到物体表面时,一部分发生反射,一部分进入物体内部,发生折射或散射。被反射的光和入射光之间存在一定的比率关系,这个比率关系可以通过菲涅尔等式进行计算。一个经常使用的例子是,当你站在湖边,直接低头看脚边的水面时,你会发现水几乎是透明的,你可以直接看到水底的小鱼和石子;但是,当你抬头看远处的水面时,会发现几乎看不到水下的情况,而只能看到水面反射的环境。这就是所谓的菲涅尔效果。事实上,不仅仅是水、玻璃这样的反光物体具有菲涅尔效果,几乎任何物体都或多或少包含了菲涅尔效果,这是基于物理渲染中非常重要的一项高光反射计算因子。
那么,我们如何计算菲涅尔反射呢?这就需要使用菲涅耳等式。真实世界的菲涅耳等式是非常复杂的,但在实时渲染中,我们通常会使用一些近似公式来计算。其中一个著名的近似公式就是Schlick 菲涅耳近似等式:
其中,F(0)是一个反射系数,用于控制菲涅耳反射的强度,v是视角方向,n是法线表面。另一个应用比较广泛的等式是Empricial菲涅耳近似等式:
其中,bias、scale和power是控制项、
使用上面的菲涅耳近似等式,我们可以在边界处模拟反射光强和折射光强/漫反射光强之间的变化。在许多车漆、水面等材质的渲染中,我们会经常使用菲涅耳反射来模拟更加真实的反射效果。
我们使用Schlick菲涅耳近似等式来模拟菲涅耳反射。效果如下:
我们新建一个Unity Shader 实现上述效果。代码如下:
Shader "Unity Shaders Book/Chapter 10/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 = 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;
}
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"
}
在之前的学习中,一个摄像机的渲染结果会输出到颜色缓冲中个,并显示到我们的屏幕上。现代的GPU允许我们把整个三维场景渲染到一个中间缓冲中,即渲染目标纹理(RTT),而不是传统的帧缓冲或后备缓冲。与之相关的是多重渲染目标(MRT),这种技术指的是GPU允许我们把场景同时渲染到多个渲染目标纹理中,而不再需要为每个渲染目标纹理单独渲染完整的场景。延迟渲染就是使用多重渲染目标的一个应用。
Unity为渲染目标纹理定义了一种专门的纹理类型——渲染纹理。在Unity中使用渲染纹理通常有两种方式:一种方式是在Project 目录下创建一个渲染纹理,然后把某个摄像机的渲染目标设置成该渲染纹理,这样一来该摄像机的渲染结果就会实时更新到渲染纹理中,而不会显示在屏幕上。使用这种方法,我们还可以选择渲染纹理的分辨率、滤波模式等纹理属性。另一种方式是在屏幕后处理时使用GrabPass命令或OnRederImage函数来获取当前屏幕图像,Unity会把这个屏幕图像放到一张和屏幕分辨率等同的渲染纹理中,下面我们可以在自定义的Pass中把它们当成普通的纹理来处理,从而实现各种屏幕特效。
我们先学习如何使用渲染纹理来模拟镜子效果。我们目标是得到如下图效果。
为此,我们需要做如下准备工作
1)新建一个场景,去掉天空盒子
2)新建材质,新建一个Shader,Shader赋给这个材质
3)场景中创建6个立方体,调整它们的位置和大小,使得它们构成围绕摄像机的房间的6面墙。场景中添加3个点光源。
4)创建3个球体和两个立方体,调整位置和大小
5)创建一个四边形(Quad),调整它的位置和大小,它将作为镜子
6)在Project视图下创建一个渲染纹理(右键单击Create->Render Texture),命名为"MirrorTexture"。它使用的纹理如下图所示。
7)最后,为了得到从镜子触发观察到的场景图像,我们还需要创建一个摄像机,并调整它的位置、裁剪平面、视角等,使得它的显示图像是我们希望的镜子图像。由于这个摄像机不需要直接显示在屏幕上,而是用于渲染到纹理。因此,我们把之前创建的MirrorTexture拖拽到该摄像机的Target Texture上。下图显示了摄像机面板和渲染纹理的相关设置。
镜子实现的原理很简单,它使用一个渲染纹理作为输入属性,并把该渲染纹理在水平方向上翻转后直接显示到物体上即可。我们修改之前创建的Shader的代码。
Shader "Unity Shaders Book/Chapter 10/Mirror" {
Properties {
//对应了由镜子摄像机渲染得到的渲染纹理
_MainTex ("Main Tex", 2D) = "white" {}
}
SubShader {
Tags { "RenderType"="Opaque" "Queue"="Geometry"}
Pass {
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
sampler2D _MainTex;
struct a2v {
float4 vertex : POSITION;
float3 texcoord : TEXCOORD0;
};
struct v2f {
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
};
//顶点着色器中计算纹理坐标
v2f vert(a2v v) {
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
o.uv = v.texcoord;
//翻转x分量的纹理坐标。这是因为,镜子里显示的图像都是左右相反的
o.uv.x = 1 - o.uv.x;
return o;
}
//在片元着色器中对渲染纹理进行采样和输出
fixed4 frag(v2f i) : SV_Target {
return tex2D(_MainTex, i.uv);
}
ENDCG
}
}
FallBack Off
}
在unity中,我们还可以在Unity Shader 中使用一种特殊的Pass 完成获取屏幕图像的目的,这就是GrabPass。当我们再Shader中定义了一个GrabPass后,Unity会把当前屏幕的图像绘制在一张纹理中,以便我们在后续的Pass中访问它。我们通常会使用GrabPass来实现诸如玻璃等透明材质的模拟,与使用简单的透明混合不同,使用GrabPass可以让我们队物体后面的图像进行更复杂的处理,例如使用法线来模拟折射效果,而不再是简单的和原屏幕颜色进行混合。需要注意的是,在使用GrabPass 的时候,我们需要额外小心物体的渲染队列设置。正如之前所说,GrabPass 通常渲染透明物体,尽管代码里并不包含混合指令,但我们往往仍然需要把物体的渲染队列设置成透明队列(即"Queue"="Transprent")。这样才可以保证当渲染该物体时,所有的不透明物体都已经被绘制在屏幕上,从而获得正确的屏幕图像。
我们用GrabPass模拟一个玻璃效果。我们可以得到类似下图的效果。这种效果实现非常简单,我们首先使用一张法线纹理来修改模型的法线信息,然后使用反射方法,通过一个Cubemap来模拟玻璃的反射,而在模拟折射时,则使用了GrabPass获取玻璃后面的屏幕图像,并使用切线空间下的法线会屏幕纹理坐标偏移后,再对屏幕图像进行采样来模拟近似的折射效果。
我们需要做如下准备工作
1)新建一个场景,去掉天空盒子
2)新建材质,新建一个Shader,Shader赋给这个材质
3)构建一个测试玻璃效果的场景,我们构建了一个由6面墙围成的封闭房间,并在房间中放置了一个立方体和一个球体,其中球体位于立方体内部,这是为了模拟玻璃对内部物体的折射效果。把材质赋给立方体
4)我们使用之前实现的创建立方体纹理的脚本(通过GameObject -> Render into Cubemap打开编辑器窗口)来创建它,如下图所示。
我们对Shader进行修改
Shader "Unity Shaders Book/Chapter 10/Glass Refraction" {
Properties {
//玻璃的纹理
_MainTex ("Main Tex", 2D) = "white" {}
//玻璃的法线纹理
_BumpMap ("Normal Map", 2D) = "bump" {}
//模拟反射的环境纹理
_Cubemap ("Environment Cubemap", Cube) = "_Skybox" {}
_Distortion ("Distortion", Range(0, 100)) = 10
//用于控制折射程度,为0时,只包含反射效果,为1时,只包含折射效果
_RefractAmount ("Refract Amount", Range(0.0, 1.0)) = 1.0
}
SubShader {
Tags { "Queue"="Transparent" "RenderType"="Opaque" }
//通过GrabPass 定义了一个抓取屏幕图像的Pass。在这个Pass中我们定义了一个字符串。
//该字符串内部的名称决定了抓取得到的屏幕图像将会存入哪个纹理中
GrabPass { "_RefractionTex" }
Pass {
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _BumpMap;
float4 _BumpMap_ST;
samplerCUBE _Cubemap;
float _Distortion;
fixed _RefractAmount;
sampler2D _RefractionTex;
float4 _RefractionTex_TexelSize;
struct a2v {
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 tangent : TANGENT;
float2 texcoord: TEXCOORD0;
};
struct v2f {
float4 pos : SV_POSITION;
float4 scrPos : TEXCOORD0;
float4 uv : TEXCOORD1;
float4 TtoW0 : TEXCOORD2;
float4 TtoW1 : TEXCOORD3;
float4 TtoW2 : TEXCOORD4;
};
v2f vert (a2v v) {
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
//通过内置的ComputeGrabScreenPos函数来得到对应抓取的屏幕图像的采样坐标
o.scrPos = ComputeGrabScreenPos(o.pos);
//计算了_MainTex和_BumpMap的采样坐标
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;
//计算该顶点对应的从切线空间到世界空间的变换矩阵,并把该矩阵的每一行分别存储在TtoW0~TtoW2中
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;
}
fixed4 frag (v2f i) : SV_Target {
//通过TtoW0等变量的w得到世界坐标
float3 worldPos = float3(i.TtoW0.w, i.TtoW1.w, i.TtoW2.w);
//计算该片元对应的视角方向
fixed3 worldViewDir = normalize(UnityWorldSpaceViewDir(worldPos));
// 对法线纹理进行采样,得到切线空间下的法线方向
fixed3 bump = UnpackNormal(tex2D(_BumpMap, i.uv.zw));
// 对屏幕图像的采样坐标进行偏移,模拟折射效果
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;
// 把法线方向从切线空间转换到世界空间下
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);
}
ENDCG
}
}
FallBack "Diffuse"
}
在前面的实现中,我们在GrabPass中使用一个字符串指明了被抓取的屏幕图像将会存储在哪个名称的纹理中。实际上,GrabPass支持两种形式。直接使用GrabPass{},然后再后续的Pass中直接使用_GrabTexture来访问屏幕图像。但是,当场景中有多个物体都使用了这样的形式来抓取屏幕时,这种方法的性能消耗比较大,因为对于每一个使用它的物体,Unity都会为它单独进行一次昂贵的屏幕抓取操作。但这种方法可以让每个物体得到不同的屏幕图像,这取决于它们的渲染队列以及渲染它们时当前的屏幕缓冲中的颜色。
使用GrabPass{"TextureName"},正如之前实现的一样,我们可以在后续的Pass中使用TextureName来访问屏幕图像。使用这种方法同样可以抓取屏幕,但Unity只会在每一帧时为第一个使用名为TextureNane的纹理的物体执行一次抓取屏幕的操作,而这个纹理同样可以在其他Pass中被访问。这种方法更搞笑,因为不管场景中有多少物体使用了该命令,每一帧中Unity都只会执行一次抓取工作,但这也意味着所有物体都会使用同一张屏幕图像。不过,在大多数情况下这已经足够了。
尽管GrabPass和之前使用的渲染纹理+额外的摄像机的方式都可以抓取屏幕图像,但它们还是有一些不同的。GrabPass的好处在于实现简单,我们只需要再Shader中写几行代码就可以实现抓取屏幕的目的。而要使用渲染纹理的话,我们首先需要创建一个渲染纹理和一个额外的摄像机,再把该摄像机的Render Target 设置为新建的渲染纹理对象,最后把该渲染纹理传递给响应的Shader。
但从效率上来说,使用渲染纹理的效率往往要好于GrabPass,尤其在移动设备上。使用渲染纹理我们可以自定义渲染纹理的大小,尽管这种方法需要把部分场景再次渲染一遍,但我么可以通过调整摄像机的渲染层来减少二次渲染时的场景大小,或使用其他方法来控制摄像机是否需要开启。而使用GrabPass获取到的图像分辨率和显示屏幕是一致的,这意味着在一些高分辨率的设备上可能会造成严重的带宽影响。而且在移动设备上,GrabPass虽然不会重新渲染场景,但它往往需要CPU直接读取后备缓冲中的数据,破坏了CPU和GPU之间的并行性,这是比较耗时的,甚至在一些移动设备上这是不支持的。
在Unity5中,Unity引入了命令缓冲来允许我们扩展Unity的渲染流水线。使用命令缓冲我们也可以得到类似抓屏的效果,它可以在不透明物体渲染后把当前的图像复制到一个临时的渲染目标纹理中,然后在那里进行一些额外的操作,例如模糊等,最后把图像传递给需要使用它的物体进行处理和显示。除此之外,命令缓冲还允许我们事先很多特殊的效果。
程序纹理
程序纹理指的是那些由计算机生成的图像,我们通常使用一些特定的算法来创建个性化图案或非常真实的自然元素,例如木头、石子等、使用程序纹理的好处在于我们可以使用各种参数来控制纹理的外观,而这些属性不仅仅是那些颜色属性,甚至可以完全不同类型的图案属性,这使得我们可以得到更加丰富的动画和视觉效果。
我们先使用一个算法来生成一个波点纹理,如下图所示。我们可以在脚本中调整一些参数,如背景颜色、波点颜色等,以控制最终生成的纹理外观。
为此,我们需要进行如下准备工作。
1)创建一个场景,去掉天空盒子
2)创建一个参数,新建一个Shader,赋给材质
3)新建一个立方体,上步材质赋给它
4)创建一个脚本ProceduralTextureGeneration.cs,拖拽到上步中的立方体中。
修改ProceduralTextureGeneration.cs 代码。
[ExecuteInEditMode]
public class ProceduralTextureGeneration : MonoBehaviour {
//为了保存生成的程序纹理,我们声明了一个Texture2D类型的纹理变量
public Material material = null;
/*
注意到,对于每个属性我们使用了get/set的方法,
为了在面板上修改属性时仍可以执行set函数,我们使用了
一个开源插件 SetProperty。这使得当我们修改了材质属性时,
可以执行_UpdateMaterial函数来使用新的属性重新生成程序纹理。
*/
//纹理的大小,数值通常是2的整数幂
#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();
}
}
#end region
void Start(){
if(material == null){
Renderer renderer = gameObject.GetComponent<Renderer>();
if(renderer == null){
Debug.LogWarning("cannot find a renderer");
return;
}
material = renderer.sharedMaterial;
}
_UpdateMaterial();
}
void _UpdateMaterial(){
if(material != null){
m_generatedTexture = _GenerateProceduralTexture();
material.SetTexture("_MainTex",m_generatedTexture);
}
}
Texture2D _GenerateProceduralTexture(){
Texture2D proceduralTexture = new Texture2D(textureWidth,textureWidth);
//定义圆与圆之间的间距
float circleInterval = textureWidth / 4.0f;
//定义圆的半径
float radius = textureWidth / 10.0f;
//定义模糊系数
float edgeBlur = 1.0f / blurFactor;
for(int w = 0; w < textureWidth; w++){
for(int h = 0; h < textureWidth; h++){
//依次画9个圆
for(int i = 0; i < 3; i++){
for(int j = 0; j < 3; j++){
Vector2 circleCenter = new Vector2(circleInterval*(i+1),circleInterval*(j+1));
//计算当前像素与圆心的距离
float dist = Vector2.Distance(new Vector2(w,h),circleCenter) - radius;
//模糊圆的边界
Color color = _MixColor(circleColor,new Color(pixel.r,pixel.g,pixel.b,0.0f),
Mathf.SmoothStep(0f,1.0f,dist*edgeBlur));
//与之前得到的颜色进行混合
pixel = _MixColor(pixel,color,color.a);
}
}
proceduralTexture.SetPixel(w,h,pixel);
}
}
proceduralTexture.Apply();
return proceduralTexture;
}
}
我们调整脚本面板中的材质参数来得到不同的程序纹理,如下图所示
在Unity中,有一类专门使用程序纹理的材质,叫做程序材质。这类材质和我们之前使用的那些材质本质上是一样的,不同的是,它们使用的纹理不是普通的纹理,而是程序纹理。需要注意的是,程序材质和它使用的程序纹理并不是在Unity中创建的,而是使用了一个名为Substance Designer的软件在Unity外部生成的。
Substance Designer是一个非常出色的纹理生成工具,很多3A的游戏项目都使用了由它生成的材质。我们可以从Unity的资源商店或网络中获取到很多免费或付费的Substance材质。这些材质都是以sbsar 为后缀的,如下图所示,我们可以直接把这些材质像其他资源一样拖入到Unity项目中。
当把这些文件导入Unity后,Unity就会生成一个程序纹理资源。程序纹理资源可以包含一个或多个程序材质,例如下图就包含了两个程序纹理——Cereals和Cereals_1,每个程序纹理使用了不同的纹理参数,因此Unity为它们生成了不同的程序纹理,例如Cereals_Diffuse和Cereals_Diffuse等。
通过单击程序材质,我们可以在程序纹理的面板上看到该材质使用的Unity Shader 及其属性、生成程序纹理使用的纹理属性、材质预览等信息。
程序材质的使用和普通材质是一样的,我们把它们拖拽到相应的模型上即可。程序纹理的强大之处很大原因在于它的多变性,我们可以通过调整程序纹理的属性来控制纹理的外观,甚至可以生成看似完全不同的纹理。下图给出了调整Cereals 程序材质的不同纹理属性得到的不同材质效果。
可以看出,程序材质的自由度很高,而且可以和Shader配合得到非常出色的视觉效果,它是一种非常强大的材质类型。