Unity Shader 入门精要——镜面效果、玻璃效果
渲染纹理
在之前的学习中,一个摄像机的渲染结果会输出到颜色缓冲中,并显示到我们的屏幕上。现代的GPU允许我们把整个三维场景渲染到一个中间缓冲中,即渲染目标纹理(RenderTarget Texture, RTT), 而不是传统的帧缓冲或后备缓冲(back buffer)。 与之相关的是多重渲染目标(Multiple Render Target, MRT),这种技术指的是GPU允许我们把场景渲染到多个渲染目标纹理中,而不再需要为每个渲染目标纹理单独渲染完整的场景。延迟渲染就是使用多重渲染目标的一个应用。
Unity为渲染目标纹理定义了一种专门的纹理类型——渲染纹理(Render Texture)。在Unity中使用渲染纹理通常有两种方式:一种方式是在Project目录下创建-一个渲染纹理,然后把某个摄像机的渲染目标设置成该渲染纹理,这样一来该摄像机的渲染结果就会实时更新到渲染纹理中,而不会显示在屏幕上。使用这种方法,我们还可以选择渲染纹理的分辨率、滤波模式等纹理属性。另一种方式是在屏幕后处理时使用GrabPass命令或OnRenderImage函数来获取当前屏幕图像,Unity会把这个屏幕图像放到一张和屏幕分辨率等同的渲染纹理中,下面我们可以在自定义的Pass中把它们当成普通的纹理来处理,从而实现各种屏幕特效。
镜面效果方法在Project视图下创建一个渲染纹理,右键单击Create→Render Texture;为了得到从镜子出发观察到的场景图像,我们还需要创建一个摄像机,并调整它的位置、裁剪平面、视角等,使得它的显示图像是我们电脑维修网希望的镜子图像。由于这个摄像机不需要直接显示在屏幕上,而是用于渲染到纹理。,我们把创建的渲染纹理拖曳到该摄像机的Target Texture上。
效果
原理镜子实现的原理很简单,它使用一个渲染纹理作为输入属性,并把该渲染纹理在水平方向上翻转后直接显示到物体上即可。
代码
// Upgrade NOTE: replaced 'mul(UNITY_MATRIX_MVP,)' ith 'UnityObjectToClipPos()' Shader "Unity Shaders Book/Chapter 10/Mirror" { Properties { _MainTex ("Main Tex", 2D) = "hite" {} } 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 = UnityObjectToClipPos(v.vertex); o.uv = v.texcoord; // Mirror needs to filp x o.uv.x = 1 - o.uv.x; return o; } fixed4 frag(v2f i) : SV_Target { return tex2D(_MainTex, i.uv); } ENDCG } } FallBack Off }
在上面的代码中,我们翻转了x分量的纹理坐标。这是因为,镜子里显示的图像都是左右相反的。并把我们创建的MirrorTexture渲染纹理拖曳到材质的Main Tex属性中,
玻璃效果在Unity中,我们还可以在UnityShader中使用一种特殊的Pass来完成获取屏幕图像的目的,这就是GrabPass。当我们在Shader中定义了一个GrabPass后,Unity会把当前屏幕的图像绘制在一张纹理中,以便我们在后续的Pass中访问它。我们通常会使用GrabPass 来实现诸如玻璃等透明材质的模拟,与使用简单的透明混合不同,使用GrabPass 可以让我们对该物体后面的图像进行更复杂的处理,例如使用法线来模拟折射效果,而不再是简单的和原屏幕颜色进行混合。
需要注意的是,在使用GrabPass的时候,我们需要额外小心物体的渲染队列设置。正如之前所说,GrabPass 通常用于渲染透明物体,尽管代码里并不包含混合指令,但我们往往仍然需要把物体的渲染队列设置成透明队列(即"Queiue"="Transparent")。这样才可以保证当渲染该物体时,所有的不透明物体都已经被绘制在屏幕上,从而获取正确的屏幕图像。
效果
原理我们使用一张法线纹理来修改模型的法线信息,然后使用了之前介绍的反射方法,通过一个Cubemap来模拟玻璃的反射,而在模拟折射时,则使用了GrabPass获取玻璃后面的屏幕图像,并使用切线空间下的法线对屏幕纹理坐标偏移后,再对屏幕图像进行采样来模拟近似的折射效果。
代码
Shader "Unity Shaders Book/Chapter 10/Glass Refraction" { Properties { _MainTex ("Main Tex", 2D) = "hite" {} //材质纹理 _BumpMap ("Normal Map", 2D) = "bump" {} 法线纹理 _Cubemap ("Environment Cubemap", Cube) = "_Skybox" {} //模拟反射的环境纹理 _Distortion ("Distortion", Range(0, 100)) = 10 //控制模拟折射时图像的扭曲程度 _RefractAmount ("Refract Amount", Range(0.0, 1.0)) = 1.0 //控制折射程度,0=只包含反射,1=只包含折射 } SubShader { // We must be transparent, so other objects are dran before this one. Tags { "Queue"="Transparent" "RenderType"="Opaque" } // This pass grabs the screen behind the object into a texture. // We can aess the result in the next pass as _RefractionTex 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; //GrabPass中定义的纹理 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 = UnityObjectToClipPos(v.vertex); o.scrPos = ComputeGrabScreenPos(o.pos); //对应被抓取屏幕图像的采样坐标 o.uv.xy = TRANSFORM_TEX(v.texcoord, _MainTex); o.uv.z = TRANSFORM_TEX(v.texcoord, _BumpMap); float3 orldPos = mul(unity_ObjectToWorld, v.vertex).xyz; fixed3 orldNormal = UnityObjectToWorldNormal(v.normal); fixed3 orldTangent = UnityObjectToWorldDir(v.tangent.xyz); fixed3 orldBinormal = cross(orldNormal, orldTangent) v.tangent.; o.TtoW0 = float4(orldTangent.x, orldBinormal.x, orldNormal.x, orldPos.x); o.TtoW1 = float4(orldTangent.y, orldBinormal.y, orldNormal.y, orldPos.y); o.TtoW2 = float4(orldTangent.z, orldBinormal.z, orldNormal.z, orldPos.z); return o; } fixed4 frag (v2f i) : SV_Target { float3 orldPos = float3(i.TtoW0., i.TtoW1., i.TtoW2.); fixed3 orldVieDir = normalize(UnityWorldSpaceVieDir(orldPos)); // Get the normal in tangent space fixed3 bump = UnpackNormal(tex2D(_BumpMap, i.uv.z)); // Compute the offset in tangent space 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.).rgb; // Convert the normal to orld space bump = normalize(half3(dot(i.TtoW0.xyz, bump), dot(i.TtoW1.xyz, bump), dot(i.TtoW2.xyz, bump))); fixed3 reflDir = reflect(-orldVieDir, 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" }
我们在SubShader的标签中将渲染队列设置成Transparent,尽管在后面的RenderType 被
设置为了Opaque。把Queue设置成Transparent可以确保该物体渲染时,其他所有不透明物体都已经被渲染到屏幕上了,否则就可能无法正确得到“透过玻璃看到的图像”。而设置RenderType 则是为了在使用着色器替换(ShaderReplacement)时,该物体可以在需要时被正确渲染。这通常发生在我们需要得到摄像机的深度和法线纹理时。
随后,我们通过关键词GrabPass定义了一个抓取屏幕图像的Pass。在这个Pass中我们定义了一个字符串,该字符串内部的名称决定了抓取得到的屏幕图像将会被存入哪个纹理中。实际上,我们可以省略声明该字符串,但直接声明纹理名称的方法往往可以得到更高的性能。
由于我们需要在片元着色器中把法线方向从切线空间(由法线纹理采样得到)变换到世界空间下,以便对Cubemap进行采样,,我们需要在这里计算该顶点对应的从切线空间到世界空间的变换矩阵,并把该矩阵的每一行分别存储在TtoW0、TtoW1和TtoW2的xyz分量中。
o.TtoW0 = float4(orldTangent.x, orldBinormal.x, orldNormal.x, orldPos.x); o.TtoW1 = float4(orldTangent.y, orldBinormal.y, orldNormal.y, orldPos.y); o.TtoW2 = float4(orldTangent.z, orldBinormal.z, orldNormal.z, orldPos.z);
我们通过TtoW0等变量的分量得到世界坐标,并用该值得到该片元对应的视角方向。
float3 orldPos = float3(i.TtoW0., i.TtoW1., i.TtoW2.); fixed3 orldVieDir = normalize(UnityWorldSpaceVieDir(orldPos));
随后,我们对法线纹理进行采样,得到切线空间下的法线方向。我们使用该值和_Distortion 属性以及_RefractionTex_TexelSize 来对屏幕图像的采样坐标进行偏移,模拟折射效果。_ Distortion 值越大,偏移量越大,玻璃背后的物体看起来变形程度越大。在这里,我们选择使用切线空间下的法线方向来进行偏移,是因为该空间下的法线可以反映顶点局部空间下的法线方向。随后,我们对scrPos透视除法得到真正的屏幕坐标,再使用该坐标对抓取的屏幕图像_RefractionTex进行采样,得到模拟的折射颜色。
// Get the normal in tangent space fixed3 bump = UnpackNormal(tex2D(_BumpMap, i.uv.z)); // Compute the offset in tangent space 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.).rgb;
之后,我们把法线方向从切线空间变换到了世界空间下(使用变换矩阵的每一行, 即TtoW0、TtoW1和TtoW2,分别和法线方向点乘,构成新的法线方向),并据此得到视角方向相对于法线方向的反射方向。随后,使用反射方向对Cubemap进行采样,并把结果和主纹理颜色相乘后得到反射颜色。,我们使用_RefractAmount 属性对反射和折射颜色进行混合,作为最终的输出颜色。
bump = normalize(half3(dot(i.TtoW0.xyz, bump), dot(i.TtoW1.xyz, bump), dot(i.TtoW2.xyz, bump))); fixed3 reflDir = reflect(-orldVieDir, bump); fixed4 texColor = tex2D(_MainTex, i.uv.xy); fixed3 reflCol = texCUBE(_Cubemap, reflDir).rgb texColor.rgb; fixed3 finalColor = reflCol (1 - _RefractAmount) + refrCol _RefractAmount;
在前面的实现中,我们在GrabPass中使用一个字符串指明了被抓取的屏幕图像将会存储在哪
个名称的纹理中,实际上,GrabPass 支持两种形式
●直接使用 GrabPass { },然后在后续的Pass中直接使用_ GrabTexture 来访问屏幕图像。,当场景中有多个物体都使用了这样的形式来抓取屏幕时,这种方法的性能消耗比较大,因为对于每一个使用它的物体,Unity 都会为它单独进行一次昂贵的屏幕抓取操作。但这种方法可以让每个物体得到不同的屏幕图像,这取决于它们的渲染队列及渲染它们时当前的屏幕缓冲中的颜色。
●使用GrabPass { "TextureName" },正如本节中的实现,我们可以在后续的Pass 中使用TextureName来访问屏幕图像。使用这种方法同样可以抓取屏幕,但Unity只会在每一帧时为第一个使用名为TextureName的纹理的物体执行一次抓取屏幕的操作,而这个纹理同样可以在其他Pass中被访问。这种方法更高效,因为不管场景中有多少物体使用了该命令,每一帧中Unity都只会执行一次抓取工作,但这也意味着所有物体都会使用同一张屏幕图像。不过,在大多数情况下这已经足够了。