本文继续对《UnityShader入门精要》——冯乐乐 第十章 高级纹理 10.1节 进行学习
一、知识回顾
可以先复习一下图形学的相关知识,参考图形学笔记七 纹理和贴图 AO
二、立方体纹理
1.天空盒
参考Scene_10_1_1,这个比较简单。
需要说明的是,在Window->Rendering->Lighting Settings中设置的天空盒子会应用于场景中的所有摄像机。如果我们希望某些摄像机可以使用不同的天空盒子,可以通过向该摄像机添加Skybox组件来覆盖掉之前的设置。也就是说,我们可以在摄像机上单击Component->Rendering->Skybox来完成对场景默认天空盒子的覆盖。
在Unity中,天空盒子是在所有不透明物体之后渲染的,而背后使用的网格是一个立方体或一个细分后的球体。
2.创建用于环境映射的立方体纹理
除了天空盒子,立方体纹理最常见的用处是用于环境映射。通过这种方法,我们可以模拟出金属质感的材质。
在Unity 5中,创建用于环境映射的立方体纹理的方法有3种:
- 第一种方法是直接由一些特殊布局的纹理创建;
- 第二种方法是手动创建一个Cubemap资源,再把6张图赋给它;
- 第三种方法是由脚本生成。
3.方法一
如果使用第一种方法,我们需要提供一张具有特殊布局的纹理,例如类似立方体展开图的交叉布局、全景布局等。然后我们只需要把该纹理的Texture Type设置为Cubemap即可,Unity会为我们做好剩下的事情。在基于物理的渲染中,我们通常会使用一张HDR图像来生成高质量的Cubemap(后面会讲到)。参考https://docs.unity.cn/cn/2019.4/Manual/shader-skybox-cubemap.html
4.方法二
第二种方法是Unity 5之前的版本中使用的方法,我们首先要在项目资源中创建一个Cubemap,然后把它的6张纹理拖拽到它的面板中。
在Unity 5中,官方推荐使用第一种方法创建立方体纹理,这是因为第一种方法可以对纹理数据进行压缩,即可以支持边缘修正、光滑反射(glossy reflection)和HDR等功能。
5.方法三
前面两种方法都需要我们提前准备好立方体纹理的图像,它们得到的立方体纹理往往是被场景中的物体所共用的。但在理想情况下,我们希望根据物体在场景中位置的不同,生成它们各自不同的立方体纹理。这是我们就可以在Unity中使用脚本来创建。这是通过利用Unity提供的Camera.RenderToCubemap函数来实现的。Camera.RenderToCubemap函数可以把任意位置观察到的场景图像存储到6张图像中,从而创建出该位置上对应的立方体纹理。
参考https://docs.unity3d.com/cn/2019.4/ScriptReference/Camera.RenderToCubemap.html
描述
从该摄像机渲染到一个静态立方体贴图。
该函数的主要用途是在编辑器中“烘焙”场景的静态立方体贴图。请参阅 下面的向导示例。如果需要实时更新的立方体贴图,请使用 RenderToCubemap 变体, 该变体使用一个具有立方体贴图尺寸的 RenderTexture,详见下文。
using UnityEngine;
using UnityEditor;
using System.Collections;
public class RenderCubemapWizard : ScriptableWizard
{
public Transform renderFromPosition;
public Cubemap cubemap;
void OnWizardUpdate()
{
string helpString = "Select transform to render from and cubemap to render into";
bool 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;
go.transform.rotation = Quaternion.identity;
// 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文件夹下才能正确执行。
注:这其实就是一个简单的ScriptableWizard插件,可以参考Unity 编辑器扩展三 EditorWindow 自定义弹窗
6.使用方法三的插件创建一个Cubemap
我们使用和上一节相同的场景,并创建一个空的GameObject对象。我们会使用该GameObject的位置信息来渲染立方体纹理。
新建一个用于存储的立方体纹理(在Project视图下单击右键,选择Create->Legacy->Cubemap来创建)。为了让脚本可以顺利将图像渲染到该立方体纹理中,我们需要在它的面板中勾选Readable选项。
从Unity菜单栏选择GameObject->Render into Cubemap,打开我们在脚本中实现的用于渲染立方体纹理的窗口,并把第一步创建的GameObject和第二步中的纹理分别拖拽到窗口中的Render From Position和Cubemap选项,如下图所示:
单击窗口的Render!按钮,就可以把从该位置观察到的世界空间下的6张图像中渲染到纹理中,如下图所示:
需要注意的是,我们需要为Cubemap设置大小,即上图中的Face size选项。Face size值越大,渲染出来的立方体纹理分辨率越大,效果可能更好,单需要占用的内存也越大,这可以由面板最下方显示的内存大小得到。
准备好需要的立方体纹理后,我们就可以对纹理使用环境映射技术。而环境映射最常见的应用就是反射和折射。
三、立方体纹理反射
使用了反射效果的物体通常看起来就像镀了层金属。想要模拟反射效果很简单,我们只需要通过入射光线的方向和表面法线方向来计算反射方向,再利用反射方向对立方体纹理采样即可。
在学习完本节后,我们可以得到类似下图的效果:
1.三个新的属性
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就是用于模拟反射的环境映射纹理,可以使用前面插件生成的cubemap
2.知识回顾:CG的reflect函数计算反射方向r
参考UnityShader精要笔记五 基础光照(漫反射+高光反射)
函数:reflect(i,n)
参数:i,入射方向;n,法线方向。可以是float、float2、float3等类型。
描述:当给定入射方向i和法线方向n时,reflect函数可以返回反射方向。下图给出了参数和返回值之间的关系。
// Get the reflect direction in world space
fixed3 reflectDir = normalize(reflect(-worldLightDir, worldNormal));
注意这个函数用的向量方向和闫令琪给出的公式有差异,首先就是上面讲的reflect用的入射光向量方向,需要取反。下面这张就是闫令琪给的图:
后面用到了内置函数WorldSpaceViewDir:
//Compute world space view direction ,from object space position
inline float3 UnityWorldSpaceViewDir(in float3 worldPos)
{
return _WorldSpaceCamearPos.xyz-worldpos;
}
然后是计算半程向量:
fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
fixed3 viewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
fixed3 halfDir = normalize(worldLightDir + viewDir);
根据向量加法,可以看出worldLightDir和viewDir的方向,与闫令琪给的图是一致的,即都是从反射点向外面发散。这一点,可以在计算高光处得到验证:
// Get the reflect direction in world space
fixed3 reflectDir = normalize(reflect(-worldLightDir, worldNormal));
// Get the view direction in world space
fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - mul(unity_ObjectToWorld, v.vertex).xyz);
// Compute specular term
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(saturate(dot(reflectDir, viewDir)), _Gloss);
o.color = ambient + diffuse + specular;
就是这个公式:
3.回顾完毕,来看vert中的reflect
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);
// Compute the reflect dir in world space
o.worldRefl = reflect(-o.worldViewDir, o.worldNormal);
TRANSFER_SHADOW(o);
return o;
}
这里使用了reflect,但并不是为了计算高光,高光会在最后使用dot求那个夹角。而现在只是求反射方向,并不牵涉光源的worldLightDir。所以就根据reflect的要求,把worldViewDir方向取反,再加上worldNormal,就可以得到反射方向了。
4.利用反射方向来对立方体纹理采样
得到了worldRefl,就可以在片元着色器中进行采样
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.worldRef1进行归一化操作。这是因为,用于采样的参数仅仅是作为方向变量传递给texCUBE函数的,因此我们没有必要进行一次归一化的操作。然后我们使用_ReflectAmount来混合漫反射颜色和反射颜色,并和光照相加后返回。
在上面的计算中,我们选择在顶点着色器中计算反射方向。当然我们也可以选择在片元着色器中进行计算,这样得到的效果更佳细腻。但是,对绝大多数人来说这种差别往往是可以忽略不计的,因此出于性能方面的考虑,我们选择在顶点着色器中计算反射方向。
5.lerp
// Mix the diffuse color with the reflected color
fixed3 color = ambient + lerp(diffuse, reflection, _ReflectAmount) * atten;
这一段代码用到的lerp,冯乐乐没有讲。效果可以在Unity中测试一下,当_ReflectAmount等于0的时候,完全在用diffuse进行反射,而_ReflectAmount等于1的时候,完全是用立方体纹理进行反射。lerp可以理解为这样:
lerp(x, y, s)
//return x + s(y - x); s范围[0, 1]
比如s=0.3,结果就是0.7x+0.3y
四、立方体纹理折射
折射的物理原理比反射复杂一些。我们在初中物理就已经接触过折射的定义:当光线从一种介质(例如空气)斜射入另一种介质(例如玻璃)时,传播方向一般会发生改变。当给定入射角时,我们可以使用斯涅尔定律(Snell’s law)来计算反射角。当光从介质1沿着和表面法线夹角为θ1的方向斜射入介质2时,我们可以使用如下公式计算折射光线与法线的夹角θ2:
其中η1和η2分别是两个介质的折射率(index of refraction)。折射率是一项重要的物理常量,例如真空的折射率是1,而玻璃的折射率一般是1.5。下图给出了这些变量之间的关系。
通常来说,当得到折射方向后我们就会直接使用它来对立方体纹理进行采样,但这是不符合物理规律的。对一个透明物体来说,一种更精确的模拟方法需要计算两次折射——一次是当光线进入它的内部时,另一次则是从它内部射出时。但是想要在实时渲染中模拟出第二次折射方向是比较复杂的,而且仅仅模拟一次得到的效果从视觉上看起来“也挺像那么回事的”。正如我们之前提到的——图形学第一准则“如果它看起来是对的,那么它就是对的”。因此,在实时渲染中,我们通常仅模拟第一次折射。
在学习完本节后,我们可以得到类似下图的效果:
参考Chapter10-Refraction.shader
1.Properties
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和前面控制反射时使用的属性类似,除此之外,我们还使用了一个属性_RefractRatio,我们需要使用该属性得到不同介质的透射比,以此来计算折射方向。
2.refract计算折射
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);
// 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来混合漫反射颜色和折射颜色,并和环境光照相加后返回。
五、立方体纹理菲涅尔反射
在实时渲染中,我们经常会使用菲涅尔反射(Fresnel reflection)来根据视角方向控制反射过程。通俗地讲,菲涅尔反射描述了一种光学现象,即当光线照射到物体表面上时,一部分发生反射,一部分进入物体内部,发生折射或散射。被反射的光和入射光之间存在一定的比率关系,这个比率关系可以通过菲涅尔等式进行计算。一个经常使用的例子是,当你站在湖边,直接低头看脚边的水面时,你会发现水几乎是透明的,你可以直接看到水底的小鱼和石子;但是当你抬头看远处的水面时,会发现几乎看不到水下的情景,而只能看到水面反射的环境。这就是所谓的菲涅尔效果。事实上,不仅仅是水、玻璃这样的反光物体具有菲涅尔效果,几乎任何物体都或多或少包含了菲涅尔效果,这是基于物理的渲染中非常重要的一项高光反射计算因子。
那么,我们如何计算菲涅尔反射呢?这就需要使用菲涅尔等式。真实世界的菲涅尔等式是非常复杂的,但在实时渲染中,我们通常会使用一些近似公式来计算。其中一个著名的计算公式就是Schlick菲涅尔近似等式:
其中,F0是一个反射系数,用于控制菲涅尔反射的强度,v是视角方向,n是表面法线。另一个应用比较广泛的等式是Empricial菲涅尔近似等式:
其中,bias、scale和power是控制项。
使用上面的菲涅尔近似等式,我们可以在边界处模拟反射光强和折射光强/漫反射光强之间的变化。在许多车漆、水面等材质渲染中,我们会经常使用菲涅尔反射来模拟更加真实的反射效果。
在本节中,我们将使用Schlick菲涅尔近似等式来模拟菲涅尔反射。在本节最后我们可以得到类似下图的效果。注意图中在模型边界处的反射现象。
1.Properties语义块中声明了用于调整菲涅尔反射的属性
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 = 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;
}
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和反射光照相乘后叠加到漫反射光照上,模拟边缘光照的效果。
当我们把_FresnelScale调节到1时,物体将完全反射Cubemap中的图像;当_Fresnel为0时,则是一个具有边缘光照效果的漫反射物体。
我们还会在15.2节中使用菲涅耳反射来混合反射和折射光照,以此为模拟一个简单的水面效果。