作者:王子饼干 ——镇江虹视游戏科技有限公司联合创始人、《多洛可小镇》制作人
原文链接:UnityShader入门精要笔记14:高级纹理A
原文发布时间:2020年3月21日
文章类型: 授权转载
人支配习惯,而不是习惯支配人。——尼古拉·阿列克谢耶维奇·奥斯特洛夫斯基《钢铁是怎样炼成的》
笔记当前使用的Unity版本:“2019.3.3”
笔记当前Unity最新发行版:“2019.3.6”
笔记当前Unity最新的预览版本:“2020.1.0.Alpha 25”
这个章节主要是阐述一些高级的纹理知识,我们之前接触过法线纹理,渐变纹理,遮罩纹理或者主纹理。这些纹理其实都是一维或者二维的纹理,在该笔记中,我们会认识三维纹理,即Cubemap,立方体纹理。
立方体纹理是一种环境映射(Environment Map)的实现的方式,环境映射可以让模型的表面反射出周围的环境,来表达它的表面的材质感。
立方体纹理一共有6张图,它有点类似于全景图,在全景图当中,我们可以通过调整方向来观察周围的事物,就好像身处其中一样。
立方体纹理和这个就差不多,立方体纹理有6张纹理拼接而成,分别是前后,上下,左右,因而也被成为立方体纹理。使用立方体纹理的优缺点都是十分鲜明的。
优点:
缺点:
CubeMap有两个特别常用的应用,一者是SkyBox天空盒子,一者是环境映射
这个其实非常简单,因为天空盒是一个内置的组件,和Shader无关。我们需要准备一些素材,比如天空盒素材,我下载了一个天空盒的素材包,在网上找的,这里就不放链接出来了,大家可以自己找。我选了一个天空盒素材出来,里面包含了6张图片和一个已经打包好的天空盒材质(虽然天空盒材质和CubeMap很像,但是两者并不一样)
查看一下天空盒材质,我们发现它的属性差不多是这个感觉。
然后我们用这个天空盒纹理来替换默认的天空盒,打开菜单栏的Window->Rendering->Lighting Settings。如下图所示
可以看见,我们可以替换一个自己想要的天空盒子。替换完成后,效果如下。
注意我们使用的天空盒材质是Unity内置的一个Shader
大家可以自己尝试一下,创建一个新的Mobile/Skybox材质,然后为它填充6张图片。
环境映射的应用有两种,一种是反射,一种是折射,不过在考虑这些之前,我们要考虑如何创建一个立方体纹理。
天空盒不是我们的重点,算是一个插曲,我们的重点是创建立方体纹理。
我们新建一个CubeMap,在Project栏右键->Create->Legacy->CubeMap
它的属性栏大概是这样,我们需要填充6张图片进去。就像玩拼图一样,不过一般来说,针对立方体纹理的单个纹理都会标注自己是xyz还是-xyz,方便我们设置。
这种创建方式应该是最基础的,其实还有两种不同的方法:
第一种,可以直接把一个球体的uv展开为6张图,然后依次填充我们的素材进去,最终打包成一张大图,接着可以把这个大图的TextureType设置为CubeMap,它就会被自动解包。
第二种,可以使用Camera.RenderToCubemap函数来实现,它可以把自己观察到的场景存储到6张图片当中,从而创建出对应的立方体纹理。
我们可以尝试一下第二种方法。
我们构建这样一个场景,一个平面,一个胶囊体。然后创建一个空的GameObject。
我们需要在这个GameObject的位置新建一个相机,然后通过相机的RenderToCubemap函数来创建一个Cubemap,创建完成后,保存到Project中。核心代码如下:
void CreateCubemap(Vector3 position,int width,string path,string name){
/* create a gameobejct at target position
and initialize a crmera C, create a cubemap through
C's RenderToCubemap Function */
Cubemap _cubemap = new Cubemap(width,DefaultFormat.LDR,TextureCreationFlags.None);
GameObject _current = new GameObject();
_current.transform.position = position;
_current.AddComponent<Camera>();
if(_current.GetComponent<Camera>().RenderToCubemap(_cubemap)){
Debug.Log("generate successfully");
GameObject.DestroyImmediate(_current);
if(Directory.Exists(path)){
AssetDatabase.CreateAsset(_cubemap,$"{path}{name}.cubemap");
}else{
Directory.CreateDirectory(path);
AssetDatabase.CreateAsset(_cubemap,$"{path}{name}.cubemap");
}
AssetDatabase.SaveAssets();
}else{
Debug.Log("generate failed");
}
}
我们需要一个位置,Cubemap的单张纹理的大小,保存路径和文件名。为了调用该函数,我们可以自己拓展一个简单的编辑器。
我们可以自己选择一个GameObject,创建这个GameObject的位置的Cubemap,然后输入宽度和文件名,再点击按钮,就可以创建一个Cubemap了,完整代码如下。
using System.Collections;
using System.Collections.Generic;
using System.IO;
using UnityEditor;
using UnityEngine;
using UnityEngine.Experimental.Rendering;
public class CMGenerator : EditorWindow
{
[MenuItem("Window/Cubemap Generator")]
static void Init(){
CMGenerator window = (CMGenerator)EditorWindow.GetWindow(typeof(CMGenerator));
window.Show();
}
const string defaultPath = "Assets/Cubemap/";
private GameObject obj;
private int width;
string filename;
void OnGUI(){
obj = (GameObject)EditorGUILayout.ObjectField("target position",obj,typeof(GameObject),true);
if(obj != null){
GUILayout.Label(obj.transform.position.ToString());
}else{
GUILayout.Label("None Object Chosen");
}
width = EditorGUILayout.IntField("width",width);
filename = EditorGUILayout.TextField("filename",filename);
if(GUILayout.Button("create cubemap")){
if(width > 0 && filename != ""){
CreateCubemap(obj.transform.position,width,defaultPath,filename);
}else{
Debug.LogError("Missing width or filename");
}
}
}
void CreateCubemap(Vector3 position,int width,string path,string name){
/* create a gameobejct at target position
and initialize a crmera C, create a cubemap through
C's RenderToCubemap Function */
Cubemap _cubemap = new Cubemap(width,DefaultFormat.LDR,TextureCreationFlags.None);
GameObject _current = new GameObject();
_current.transform.position = position;
_current.AddComponent<Camera>();
if(_current.GetComponent<Camera>().RenderToCubemap(_cubemap)){
Debug.Log("generate successfully");
GameObject.DestroyImmediate(_current);
if(Directory.Exists(path)){
AssetDatabase.CreateAsset(_cubemap,$"{path}{name}.cubemap");
}else{
Directory.CreateDirectory(path);
AssetDatabase.CreateAsset(_cubemap,$"{path}{name}.cubemap");
}
AssetDatabase.SaveAssets();
}else{
Debug.Log("generate failed");
}
}
}
如果不懂编辑器拓展的小伙伴,把上面这段代码写入一个脚本后,可以在Window->CubemapGenerator找到这个编辑器。
点击Create Cubemap后,我们可以在Project视图中找到刚才创建的这个对象。
是不是很棒~
接下来我们通过Shader来实现反射效果,反射的原理是,我们通过相机的视角方向和物体模型的法线方向来计算反射方向。然后通过反射方向在CubeMap当中进行采样。采样得到的结果和主纹理进行线性插值。以下是完整的代码
Shader "UShaderMagicBook/EnvironmentMap"{
Properties{
_MainTex("Main Texture",2D) = "white"{}
_BaseColor("Base Color",Color) = (1.0,1.0,1.0,1.0)
_ReflectColor("Reflection Color",Color) = (1.0,1.0,1.0,1.0)
_ReflectAmount("Reflection Amount",Range(0,1)) = 1
_Cubemap("Reflection Cubemap",Cube) = "skybox"{}
}
SubShader{
pass{
Tags{"LightMode"="ForwardBase"}
CGPROGRAM
#include "UnityCG.cginc"
#include "Lighting.cginc"
#include "AutoLight.cginc"
#pragma vertex Vertex
#pragma fragment Pixel
struct vertexInput{
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 texcoord : TEXCOORD0;
};
struct vertexOutput{
float4 pos : SV_POSITION;
float3 worldNormal : TEXCOORD0;
float3 worldPos : TEXCOORD1;
float3 worldViewDir : TEXCOORD2;
float3 worldReflectDir : TEXCOORD3;
float2 uv : TEXCOORD4;
SHADOW_COORDS(5)
};
fixed4 _BaseColor;
fixed _ReflectAmount;
fixed4 _ReflectColor;
samplerCUBE _Cubemap;
sampler2D _MainTex;
vertexOutput Vertex(vertexInput v){
vertexOutput 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.worldReflectDir = reflect(-o.worldViewDir,o.worldNormal);
o.uv = v.texcoord;
TRANSFER_SHADOW(o)
return o;
}
fixed4 Pixel(vertexOutput i):SV_TARGET{
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
fixed3 worldViewDir = normalize(i.worldViewDir);
fixed3 albedo = tex2D(_MainTex,i.uv).xyz * _BaseColor.xyz;
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
fixed3 diffuse = _LightColor0.xyz * albedo * saturate(dot(worldNormal,worldLightDir));
fixed3 reflection = texCUBE(_Cubemap,i.worldReflectDir).xyz * _ReflectColor.xyz;
UNITY_LIGHT_ATTENUATION(atten,i,i.worldPos);
fixed3 color = ambient + lerp(diffuse,reflection,_ReflectAmount) * atten;
return fixed4(color,1.0);
}
ENDCG
}
}
}
完成以上代码的编写后,我们可以新建一个球体,在把球体拖入我们之前所编写的那个Cubemap Generator中,生成一个Cubemap,然后将Cubemap作为这个球体的反射输入
另外,关于什么是插值函数lerp,可以参看我的游戏开发技术杂谈2:
王子饼干:游戏开发技术杂谈2:理解插值函数lerp111 赞同 · 6 评论文章
我们得到的最终的效果如下
这种就是所谓的环境映射,它的最大的缺点是无法实时反射,如果玩家移动到它旁边,它是无法反射出玩家的。我们先对代码做一些解读。
在顶点着色器中,我们直接计算了视角的反射方向,于是我们先计算出视角方向,然后根据视角方向和法线方向,计算出反射方向。
vertexOutput Vertex(vertexInput v){
vertexOutput 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.worldReflectDir = reflect(-o.worldViewDir,o.worldNormal);
o.uv = v.texcoord;
TRANSFER_SHADOW(o)
return o;
}
其它的基本不变,只不过我的vertexOutput这个结构体里面又增加了不少内容。
而在片元着色器中,我们通过反射方向对立方体纹理进行采样。采样的结果需要和漫反射颜色进行混合,这样可以根据_ReflectAmount在漫反射颜色和反射之间平滑的过渡。
又要回到初中的物理课堂啦,折射是什么意思呢?它表示,当光线从介质A进入介质B时,会发生方向的偏移。折射会产生很多常见的物理现象,比如水杯中的吸管看起来想断开了一样,或者海市蜃楼,又或者放大镜,都是由于光的折射。
当我们只知道光线的入射角时,我们是没有办法计算出折射角的,因为根据介质A和介质B的材质不同,会导致折射率的不同。这里我们需要了解斯涅尔定律(Snell's Law)来计算反射角
其中θ1和θ2就是入射角和反射角,而η1和η2就是两个介质的折射率。一般来说,真空的折射率是1,而玻璃的折射率是1.5
通常我们需要计算两次折射,一次是当光线进入物体时,一次是当光线从物体出来时,不过由于一次折射已经足够像样了,为了不浪费性能,我们一般选择只计算一次。
我们可以轻松的把η1移动到右边,得到η2/η1,这样我们只需要用一个参数就可以控制了,这个参数就是所谓的透射比
接下来我们通过代码来认识一下折射。
Shader "UShaderMagicBook/Refract"{
Properties{
_BaseColor("Base Color",Color) = (1.0,1.0,1.0,1.0)
_RefractColor("Refract Color",Color) = (1.0,1.0,1.0,1.0)
_RefractAmount("Refract Amount",Range(0,1)) = 1.0
_RefractRatio("Refract Ratio",Range(0.1,1)) = 0.5
//透射比不能为0
_RefractTex("Refract Texture",Cube) = "skybox"{}
}
SubShader{
pass{
Tags{"LightMode"="ForwardBase"}
CGPROGRAM
#pragma vertex Vertex
#pragma fragment Pixel
#include "Lighting.cginc"
#include "AutoLight.cginc"
struct vertexInput{
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 texcoord : TEXCOORD0;
};
struct vertexOutput{
float4 pos : SV_POSITION;
float3 worldPos : TEXCOORD0;
float3 worldNormal : TEXCOORD1;
float3 worldViewDir : TEXCOORD2;
float2 uv : TEXCOORD3;
float3 worldRefractDir : TEXCOORD4;
SHADOW_COORDS(5)
};
fixed4 _BaseColor;
fixed4 _RefractColor;
float _RefractAmount;
float _RefractRatio;
samplerCUBE _RefractTex;
vertexOutput Vertex(vertexInput v){
vertexOutput o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldPos = mul(unity_ObjectToWorld,v.vertex).xyz;
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.uv = v.texcoord;
o.worldViewDir = UnityWorldSpaceViewDir(o.worldPos);
o.worldRefractDir = refract(normalize(o.worldViewDir),normalize(o.worldNormal),_RefractRatio);
//通过refract函数来计算折射,由于斯尼尔定律中需要计算正弦函数,又我们的方向都是向量
//所以这里需要进行正则化。
TRANSFER_SHADOW(o)
return o;
}
fixed4 Pixel(vertexOutput i):SV_TARGET{
fixed3 worldNormal = normalize(i.worldNormal);
//fixed3 worldViewDir = normalize(i.worldViewDir);
fixed3 lightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * _BaseColor.xyz;
fixed3 diffuse = _LightColor0.xyz * _BaseColor.xyz * saturate(dot(worldNormal,lightDir));
fixed3 refraction = texCUBE(_RefractTex,i.worldRefractDir) * _RefractColor.xyz;
UNITY_LIGHT_ATTENUATION(atten,i,i.worldPos)
return fixed4(ambient + lerp(diffuse,refraction,_RefractAmount) * atten,1.0);
}
ENDCG
}
}
}
它的最终效果如下
虽然看起来像是一个透镜,实际上它并不是,它甚至是不透明的,一切都是在我们实现计算好的Cubemap上进行采样,如果这个时候它的后面新增了一个模型,它是看不见的。不过目前这不是重点,正好也方便了我们只去关心折射是怎么实现的。
从代码实现上来看,折射和反射的实现方法差不多,只不过一个使用了reflect函数,一个使用了refract函数,由于refract函数需要一个透射比参数,所以我们只不过是比反射多了一个浮点输入而已。其他的基本上没有任何区别。
ok,我们本章到这里就结束了,稍微回顾一下,我们讲了如何创建立方体纹理,并且讲到了几种最常见的立方体纹理的应用,天空盒,反射,折射。难度不是非常大,几乎都是在原有的基础上增加的。这些都还算是比较基础的内容,希望能对大家有帮助。
另外,本次使用到的所有代码都已经上传到了我的github,在下面的链接中可以找到。如果你喜欢这个系列的文章,欢迎给我的github贡献一颗⭐,感谢阅读。