Unity着色器训练营(3) -基于图片的光照着色器(下)
我们已经分享了Unity着色器训练营(3) - 替换着色器方法(上),在上篇中我们讲解了如何使用替换着色器(Replacement Shader)的方法。今天介绍基于图片的光照(Image Based Lighting,下文缩写为IBL)着色器。我们会从原理着手一步步阐述IBL着色器的实现,结合实例引导各位掌握IBL着色器的使用。
往期内容回顾
图 01
图01中的场景就是我们最后完成的IBL的效果场景。实际上IBL就是根据图片的颜色信息来对场景中的某些物体进行光照处理。开始IBL介绍之前,我们先进行基于物理的着色(Physically Based Shading)知识的快速入门。
基于物理的着色
图 02
基于物理着色的光照相关的内容主要有四种,Direct Diffuse直接漫反射光照,Direct Specular直接高光光照,Indirect Diffuse 间接漫反射光照,Indirect Specular间接高光光照。这里我们首先需要搞明白什么是漫反射?什么是高光?
图 03
所谓漫反射,主要就是不同位置的法线与反射光照,通过点乘产生的光照处理。例如法线与反射光同向的,点乘值为1;法线与反射光垂直的,点乘值为0;法线与反射光反向的,点乘值为-1。漫反射可以表现3D物体在空间中的光照效果。
但是漫反射无法展现这个3D物体表面的光滑和毛糙程度,实际上就像给观看者戴了一副毛玻璃的眼镜一样,只能知其明与暗,但是无法获知其实际表面光照的细节。这时候就需要考虑高光反射了。
图 04
我们可以仔细观察图04。左边显示的:当物体的表面非常毛糙,它产生的反射就比较散乱。而右边显示的:当物体的表面比较光滑的时候,它产生的反射就比较集中,光就有聚焦,右边的就是高光反射的效果。这样越是直接对准镜头的区域,高光的亮度就越是明显。
图 05
现在比较下直接光照与间接光照。直接光就像是我们直接计算场景中已经给定的方向光、点光源等等一样。间接光照就是通过其他物体反射过来的光产生的光照效果。
以这个场景为例,我有意调整了太阳、地球、月亮的尺寸,以便于展示,不然光这样小小的场景,按照太阳这个尺寸,是完全看不到地球和月亮的。太阳就是一个光源,由于其非常巨大,我们可以将太阳光想象为一个方向光,这个光就是直接光照。但是作为地球的卫星月亮,也会和地球一样接受太阳的光照,它还会把部分光照反射到地球上,这个就是间接光照。
那么如何对场景中进行基本的直接光照和间接光照处理呢?
如果场景中的GameObject没有勾选Static,并且在该场景的光照未进行过光照的烘焙,那么这个GameObject只接受直接光照的影响。如果这个GameObject勾选了Static状态,并且该场景进行过光照的烘焙,那么这个GameObject还会接受间接光照的影响。烘焙方式是通过Window → Lighting → Settings,取消Auto Generate的勾选,点击右下角的Generate Lighting按钮,可以烘焙出场景的光照信息(会在该场景的同级目录生成一个与场景同名的文件夹,光照烘焙数据就在其中)。勾选Auto Generate选项的作用是,可以让我们在调试场景的时候无需频繁手动点击Generate Lighting按钮进行烘焙,但自动烘焙的结果并不会被储存起来。
图 06
图06是之前的茶室场景,只不过调整了材质,以便于展示。现在我使用的是Legacy的Diffuse漫反射Shader,场景里只有一个方向光。没有勾选静态物体做间接光照烘焙,所以现在所展示的就是使用了接受直接漫反射光影响的效果。使用一个简单的方向光就得 40 39139 40 15886 0 0 8771 0 0:00:04 0:00:01 0:00:03 8771到场景中这些物体的光效果。
图 07
图07场景就是在之前图06场景的基础上进行间接光照烘焙,比起之前的场景,我们明显可以发现有些间接光照产生的区域。因为我特意调整了方向光的颜色与角度像夕阳一样,所以可以看到很明显的一些效果。
首先间接光照使得整个场景比起先前的更明亮了一些,因为这是多重间接光照反射的结果,这些漫反射的光,还会去给周围物体产生光照效果。接着,我们可以看些特定的区域,比如说茶壶壶嘴的部分,这部分茶壶收到了正面的直接光照,然后反射了部分到桌子上。桌子腿和地板的相交叉的部分也可以看到明显的反射光影响。而且这些光的反射不会马上就结束了,会有一个不断的反射过程,这些光就是“间接漫反射”。
图 08
以此类推,间接高光其实也是相近的情况,就是通过其他物体反射过来的光而产生的高光。综合起来,其实就是类似于照镜子的效果,不过镜子的光滑与毛糙程度各有不同罢了。图08中这二个茶壶就表现了不同毛糙程度产生间接光的效果,第二个茶壶表面光滑,间接光产生的效果就非常明显。
图 09
事实上,Unity的Standard Shader就可以调节出这种不同粗糙程度的间接光照效果,Material窗口可以看到金属质感(Metallic)和平滑度(Smoothness)选项都为1,就明显是光滑的镜面效果,调整Smoothness为越接近于0,右边的效果就与左边传统高光Shader效果相近。
图 10
细探究,就像是每个像素实际都收到了各种光照信息的影响。就像图10场景中的茶壶,收到多个光照的影响,会产生综合的光照效果。
图 11
针对像素集合的图片这种情况,IBL是一种很好的方式来计算场景中间接光。现在我们会继续集中在间接光部分,而暂时忽略直接光。我们现在将场景中的光源想象成一个点,这个点向各个方向观察场景中的物体。这个点就像个全景摄像头一样观察整个场景,实际上场景内所有图像采集就变成了立方体贴图(Cubemap)。
立方体贴图是个特殊的纹理,它在采样的时候是可以根据三维坐标系,获得法线贴图的相关计算。你可以想象为它是个立方体的盒子,六个面都由不同的图片组成。从中心发射出来的射线都可以到达任意六个面上任意一个体素(Texel)点。好比是从中间创建了6个摄像机,每个摄像机有90度的角度差,这样覆盖了立方体所有的角度。
图 12
图12的场景中我使用了立方体贴图作为天空盒,中间显示的这张图就是该贴图的原貌,一个拥有非常丰富灯光效果的大棚化妆间的场景,很适合做IBL的画面表现。右边的部分是该立方体贴图的资源配置信息,我们可以明显看到它的纹理形状(Texture Shape)为盒状(Cube),设置相关的压缩选项有最大尺寸为512,重设尺寸算法是Mitchell,以及压缩设置为None。
开始第一个IBL着色器
有了这些重要的基础知识,我们可以具体学习IBL着色器了。我们先创建一个新的场景,同样的也使用这个立方体贴图作为天空盒的材质。方法很简单,只要将这个立方体贴图直接拖拽到场景视图中,天空盒马上就发生了变化。我们也可以通过菜单栏 Window → Lighting → Settings 找到这个天空盒的设置。
图 13
在图14场景中创建一个Sphere,而Material使用的是CopperRock_IBL_Start,这个材质的Shader就是我们第一个IBL相关的着色器:IBL_Start。这里所展示的PBR材质资源均来自于第三方,拥有完整的资源对应。
图 14
IBL_Start.Shader源码:
Shader "Shader Course/03/IBL/Start"
{
Properties
{
_MainTex("Albedo Texture", 2D) = "white" {}
[NoScaleOffset]
_NormalTex("Normal Texture", 2D) = "bump" {}
}
SubShader
{
Pass
{
Tags{ "LightMode" = "ForwardBase" }
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 tangent : TANGENT;
float2 uv : TEXCOORD0;
};
struct v2f
{
float4 vertex : SV_POSITION;
float2 uv : TEXCOORD0;
float3 wNormal : TEXCOORD1;
float3 wTangent : TEXCOORD2;
float3 wBitangent : TEXCOORD3;
};
sampler2D _MainTex;
half4 _MainTex_ST;
sampler2D _NormalTex;
v2f vert(appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
o.wNormal = UnityObjectToWorldNormal(v.normal);
o.wTangent = UnityObjectToWorldNormal(v.tangent.xyz);
o.wBitangent = cross(o.wNormal, o.wTangent) * v.tangent.w *
unity_WorldTransformParams.w;
return o;
}
fixed4 frag(v2f i) : SV_Target
{
half3 albedoColor = tex2D(_MainTex, i.uv);
half3 normalTex = tex2D(_NormalTex, i.uv) * 2 - 1;
half3 N = normalize(i.wTangent * normalTex.r +
i.wBitangent * normalTex.g + i.wNormal * normalTex.b);
half4 color = 0;
color.rgb = albedoColor;
return color;
}
ENDCG
}
}
}
这个脚本是开始撰写IBL的必要准备,内容上基于的普通光照的实现。反射纹理(Albedo Texture)其实引用的就是Main Texture主纹理。这里大多数的参数都是参照PBR,基于物理渲染的Shader的写法,即Unity的Standard Shader。
可能需要特意指出的是在副切线计算的时候添加了:v.tangent.w * unity_WorldTransformParams.w。它的结果是副切线的正负值,因为OpenGL与DX的纹理空间UV起始方向不同所需进行的处理。half3 normalTex = tex2D(_NormalTex, i.uv) * 2 - 1; 这里法线贴图取出的都是颜色值,值域(0~1),因为实际法线值域应该是(-1~1),所以需要做如此的计算。与第二期计算法线贴图颜色的方式正好相反。这里虽然会对法线做计算,但是最后返回的还是主纹理的颜色值,因此我们看到的主纹理的显示。
接着我们要查看第二个着色器(IBL_Diffuse)的效果,目的是加入漫反射的处理。将Material换为CopperRock_IBL_Diffuse,重要的是将立方体贴图Circus_Backstage_3k_o与着色器的IBL Cubemap做关联。
图 15
IBL_Diffuse.Shader 源码:
Shader "Shader Course/03/IBL/Diffuse"
{
Properties
{
_MainTex("Albedo Texture", 2D) = "white" {}
[NoScaleOffset]
_NormalTex("Normal Texture", 2D) = "bump" {}
_IBLTexCube("IBL Cubemap", Cube) = "black" {}
}
SubShader
{
Pass
{
Tags{ "LightMode" = "ForwardBase" }
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 tangent : TANGENT;
float2 uv : TEXCOORD0;
};
struct v2f
{
float4 vertex : SV_POSITION;
float2 uv : TEXCOORD0;
half3 wNormal : TEXCOORD1;
half3 wTangent : TEXCOORD2;
half3 wBitangent : TEXCOORD3;
};
sampler2D _MainTex;
half4 _MainTex_ST;
sampler2D _NormalTex;
samplerCUBE _IBLTexCube;
v2f vert(appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
o.wNormal = UnityObjectToWorldNormal(v.normal);
o.wTangent = UnityObjectToWorldNormal(v.tangent.xyz);
o.wBitangent = cross(o.wNormal, o.wTangent) *
v.tangent.w * unity_WorldTransformParams.w;
return o;
}
#define DIFFUSE_MIP_LEVEL 5
half3 SampleTexCube(samplerCUBE cube, half3 normal, half mip)
{
return texCUBElod(cube, half4(normal, mip));
}
fixed4 frag(v2f i) : SV_Target
{
half3 albedoColor = tex2D(_MainTex, i.uv);
half3 normalTex = tex2D(_NormalTex, i.uv) * 2 - 1;
half3 N = normalize(i.wTangent * normalTex.r +
i.wBitangent * normalTex.g + i.wNormal * normalTex.b);
half3 indirectDiffuse = SampleTexCube(_IBLTexCube, N, DIFFUSE_MIP_LEVEL);
half3 diffuse = albedoColor * indirectDiffuse;
half4 color = 0;
color.rgb = diffuse;
return color;
}
ENDCG
}
}
}
这里有了新的参数 _IBLTexCube,它是引入IBL的Cubemap纹理里来做运算。Cube就是立方体纹理,默认用黑色图,就是一旦没有纹理引入,就用一个全黑的场景,这样就会有不产生任何实际影响的光照出来。
在Shader内部数据容器接口需要创建一个对应的samplerCUBE,来获得这个IBLTexCube。在frag方法中,创建indirectDiffuse,通过texCUBE方法(采样Cubemap纹理,然后与第二个参数混合,这里是法线),引入参数IBLTexCube和已经归一化的法线值N,计算出间接漫反射光照。这里用这里用了texCUBElod方法,实际是texCUBE包含LOD运算的。texCUBElod通过细节等级采样cubemap,可以根据Diffuse的mipmap的不同细节等级(LOD),来调整间接漫反射。
对比图15和图 14,你会发现图15 这个球体其实有比较清楚的明暗部分,但是效果还是一般,因为没有高光,最好能有些间接高光给到显示。这时候就需要引入目光向量。
图 16
从物体表面到摄像机的是观察方向,而从摄像机向物体的就是目光向量。为了表现好IBL的效果,需要在片元着色器内对于eyeVec做高光相关结合的处理。而拥有这部分“目光向量”处理的,就是接下来要介绍的IBL_Full着色器文件了。
IBL_Full.Shader 源码:
Shader "Shader Course/03/IBL/Full"
{
Properties
{
_MainTex("Albedo Texture", 2D) = "white" {}
[NoScaleOffset]
_NormalTex("Normal Texture", 2D) = "bump" {}
[NoScaleOffset]
_IBLTexCube("IBL Cubemap", Cube) = "black" {}
_Gloss("Gloss", Range(0, 1)) = 0.5
_Reflectivity("Reflectivity", Range(0, 1)) = 0.5
}
SubShader
{
Pass
{
Tags{ "LightMode" = "ForwardBase" }
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 tangent : TANGENT;
float2 uv : TEXCOORD0;
};
struct v2f
{
float4 vertex : SV_POSITION;
float2 uv : TEXCOORD0;
float3 wNormal : TEXCOORD1;
float3 wTangent : TEXCOORD2;
float3 wBitangent : TEXCOORD3;
float3 eyeVec : TEXCOORD4;
};
sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _NormalTex;
samplerCUBE _IBLTexCube;
float _Gloss;
float _Reflectivity;
v2f vert(appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
o.wNormal = UnityObjectToWorldNormal(v.normal);
o.wTangent = UnityObjectToWorldNormal(v.tangent.xyz);
o.wBitangent = cross(o.wNormal, o.wTangent) *
v.tangent.w * unity_WorldTransformParams.w;
float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
o.eyeVec = normalize(worldPos - _WorldSpaceCameraPos);
return o;
}
#define DIFFUSE_MIP_LEVEL 5
#define GLOSSY_MIP_COUNT 6
half3 SampleTexCube(samplerCUBE cube, half3 normal, half mip)
{
return texCUBElod(cube, half4(normal, mip));
}
fixed4 frag(v2f i) : SV_Target
{
half3 mainTex = tex2D(_MainTex, i.uv);
half3 normalTex = tex2D(_NormalTex, i.uv) * 2 - 1;
half oneMinusReflectivity = 1 - _Reflectivity;
half roughness = 1 - _Gloss;
half3 N = normalize(i.wTangent * normalTex.r +
i.wBitangent * normalTex.g + i.wNormal * normalTex.b);
half3 eyeVec = normalize(i.eyeVec);
half3 R = reflect(eyeVec, N);
half3 albedoColor = lerp(0, mainTex.rgb, oneMinusReflectivity);
half3 directDiffuse = saturate(dot(N, _WorldSpaceLightPos0));
half3 indirectDiffuse = SampleTexCube(_IBLTexCube, N, DIFFUSE_MIP_LEVEL);
half3 diffuse = albedoColor * (directDiffuse + indirectDiffuse);
half3 indirectSpecular = SampleTexCube(_IBLTexCube, R,
roughness * GLOSSY_MIP_COUNT) * _Reflectivity;
half4 color = 0;
color.rgb = diffuse + indirectSpecular;
return color;
}
ENDCG
}
}
}
可以看到在v2f添加了eye vector目光的向量。当摄像机发生移动的时候,高光(Specular)在物体表面也会随之移动。将本地网格转换到Unity世界空间与顶点做矩阵的相乘,获取结果的xyz,即世界位置。将世界位置减去摄像机在Unity世界空间中的位置,进行归一化处理,就可以获得目光向量。
float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
o.eyeVec = normalize(worldPos - _WorldSpaceCameraPos);
图 17
得到目光向量之后,我们就可以来计算其反射向量。
图 18
标题显示的公式就是反射向量计算公式,各自颜色标注的就是不同的向量,黄色目光向量i,蓝色法线n,红色反射向量v。目光向量i点乘蓝色法线n,可以得到一个中间换算的尺度,这个的结果乘以两倍的蓝色法线n,可以得到向量计算用的一条边,白色的那个。通过黄色目光向量i减去白色向量,就可以得到红色反射向量v。half3 R = reflect(eyeVec, N);就实现了反射向量的计算。通过texCUBElod将反射向量与天空盒的立方体纹理采样,进而获得间接高光。
图 19
大家可能还注意到在着色器选项还有二个参数,分别是光泽度(Gloss)和反射系数(Reflectivity)。
图 20
图20 的场景其实也能说明现在的概念。左边表面光滑的,就是Gloss光泽表面;右边表面有点毛糙,就是Roughness粗糙表面,而粗糙值其实就是1减去gloss光泽度值而得到的。
half3 indirectSpecular = SampleTexCube(_IBLTexCube, R, roughness * GLOSSY_MIP_COUNT) * _Reflectivity;
在着色器代码中,在纹理映射(mipmap)的lod值由粗糙度乘以光滑纹理映射的参数而获得。我们为了方便计算,就简单将纹理映射值与粗糙度正比例关系来处理。
half oneMinusReflectivity = 1 - _Reflectivity;
…
half3 albedoColor = lerp(0, mainTex.rgb, oneMinusReflectivity);
albedoColor 通过1减反射率的值作为权重,线性差值计算主纹理的颜色,反射率越高,物体越明亮,主纹理颜色越不明显;反射率越低,物体越暗淡,主纹理颜色越明显。越是明亮,物体的金属质感也越明显。
color.rgb = diffuse + indirectSpecular;
直接叠加直接与间接漫反射和间接高光就获得了最终需要呈现的颜色值。
图 21
最后,在图21的场景中我们再放置了另外二个使用相同材质的球体,以呈现更好的展示效果。尤其是左边使用类似于地板质感的PBR材质,对于IBL反射的效果最为明显,我们得以看到朦胧的天空盒“景色”。
小结
IBL基于图片光照着色器的内容就介绍这里,Unity着色器训练营第三期的技术内容也暂告一段落。该项目中的资源整理后,会与第一期第二期的资源一同分享给各位开发者。当然我们的Unity着色器训练营还会继续,请大家关注我们Unity的官方微信发布的直播信息,敬请期待!更多精彩内容尽在Unity中文官方论坛(Unitychina.cn)!
更多着色器内容
官方活动
活动一:Unity技术路演广州站报名启动
活动信息:3月25日(周日)广州
报名地址:https://www.bagevent.com/event/2018UnityRoadshowgz
时间:截止到3月31日 线上促销
促销地址:https://store.unity.com/cn/offer/plus-triple-boost
活动信息:5月11-13日 北京国家会议中心
售票官网: http://unite2018.csdn.net/ 或者直接扫描下图二维码进行购票!
点击“阅读原文”访问Unity中文官方论坛 !