Unity Shader学习记录(四)

Unity Shader学习记录(四)

  前文结尾提到的一种能在模型平面上渲染出凹凸感的技术,也就是法线贴图技术,其原理并不复杂;考虑到凹凸感本身来源于模型不同表面特性对光照的反应,而影响光照的最大因素之一当然是法线了,平面上每一点的法线都是一样的,这也就是为什么渲染到平面上的纹理看起来是平的,如果给与一个平面上不同点以不同的法线参数,那么理论上就能实现凹凸不平的效果。

法线贴图

  法线贴图技术是现在非常常见的一种优化技术,它的核心要求是在保证一定视觉效果的前提下尽量降低模型复杂度。用一个经典的例子来说,一栋楼房,它的外表有窗户,还有一些花纹,这些东西在现实中都是楼房表面的凹凸部分,遇到光照时需要产生一定的光影效果。
  但是在计算机图形图像中,如果要这些窗户或者花纹对光照产生反应,那么必须要在模型上反应出来,换言之这个模型必须使用大量的多边形来制作花纹和窗户,就像真正的楼房那样。这种高精度的模型用在工业渲染或者室内设计之类的静态渲染条件下是没有太大问题的,因为他们有足够的渲染时间;然而在游戏这种即时性要求极高的场景中,高精度模型的使用乃至大量使用是不可接受的。
  如果使用低精度模型,那么楼房表面就会变成平面,这时使用漫反射贴图将那些花纹和窗户渲染上去的话,整个效果就是平面的,没有丝毫的凹凸感,对光照的反应也十分呆板。
  为了解决这个矛盾,人们通过外部引入法线信息的方式为平面强行“赋予”了凹凸感,就像之前说的那样,凹凸感来自于模型上各个点的法线不同,那么如果让平面上每一点都从一个外部信息源获得额外法线信息那不就能创造出凹凸感么。
  由此法线贴图诞生,它本质上也是贴图,但它并不记录漫反射颜色之类的信息,而是将漫反射贴图上对应点的法线信息记录到颜色中,读取的时候便可以从中解析出某个点规定的法线信息。
  应用法线贴图的Shader如下所示,有两种类型,一种是切线空间的法线贴图,另一种是世界空间的法线贴图,两者并没有实质的区别,仅仅是运算过程中涉及到的坐标转化不同。Unity在新版中的默认Shader里,法线贴图的相关运算全部用了世界空间的方式,在这里给出两种方式的代码。

Shader "Custom/FinalTestShader" {
    Properties {
        _Color ("Color", Color) = (1,1,1,1)
        _MainTex ("Albedo (RGB)", 2D) = "white" {}
        _BumpTex ("Bump Map", 2D) = "white" {}
        _BumpScale ("Bump Scale", Float) = 1.0
        _Specular ("Specular", Color) = (1,1,1,1)
        _Gloss ("Gloss", Range(1.0,128)) = 8.0
    }
    SubShader {
        Pass {
            Tags {"LightMode"="ForwardBase"}

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "Lighting.cginc"

            float4 _Color;
            sampler2D _MainTex;
            float4 _MainTex_ST;
            sampler2D _BumpTex;
            float4 _BumpTex_ST;
            float _BumpScale;
            float4 _Specular;
            float _Gloss;

            struct a2v {
                float4 vertex : POSITION;
                float3 normal : NORMAL;
                float4 tangent : TANGENT;
                float4 texcoord : TEXCOORD0;
            };

            struct v2f {
                float4 pos : SV_POSITION;
                float4 uv : TEXCOORD0;
                float3 tangentLightDir : TEXCOORD1;
                float3 tangentViewDir : TEXCOORD2;
            };

            v2f vert(a2v v) {
                v2f o;
                o.pos = UnityObjectToClipPos(v.vertex);
                o.uv.xy = TRANSFORM_TEX(v.texcoord, _MainTex);
                o.uv.zw = TRANSFORM_TEX(v.texcoord, _BumpTex);
                float3 binormal = normalize(cross(normalize(v.normal), normalize(v.tangent.xyz))) * v.tangent.w;
                float3x3 rotation = float3x3(v.tangent.xyz, binormal, v.normal);
                o.tangentLightDir = mul(rotation, ObjSpaceLightDir(v.vertex)).xyz;
                o.tangentViewDir = mul(rotation, ObjSpaceViewDir(v.vertex)).xyz;
                return o;
            }

            fixed4 frag(v2f i) : SV_TARGET {
                fixed3 lightDir = normalize(i.tangentLightDir);
                fixed3 viewDir = normalize(i.tangentViewDir);
                float3 unpackNormal = UnpackNormal(tex2D(_BumpTex, i.uv.zw));
                unpackNormal.xy *= _BumpScale;
                unpackNormal.z = sqrt(1.0 - saturate(dot(unpackNormal.xy, unpackNormal.xy)));
                fixed3 albedo = tex2D(_MainTex, i.uv.xy).rgb * _Color.rgb;
                fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
                fixed3 diffuse = _LightColor0.rgb * albedo * saturate(dot(unpackNormal, lightDir));
                fixed3 halfDir = normalize(lightDir + viewDir);
                fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(saturate(dot(unpackNormal, halfDir)), _Gloss);
                return fixed4(ambient + diffuse + specular, 1.0);
            }

            ENDCG
        }
    }
    FallBack "Diffuse"
}

  注意到在a2v结构中多保存了一个数据,也就是顶点的切线方向,它是个四元组表达,因为采用了齐次坐标,所以在后续计算中会分开使用其前三个分量以及第四个分量。
  顶点计算中首先分别获取了漫反射贴图和法线贴图的坐标,分别储存在uv四元组的前后;接着计算了副法线,这是垂直于法线和切线的一个向量,方向根据所使用的坐标系特点来确定,在Unity中需要叉乘顶点法线与切线。

float3 binormal = normalize(cross(normalize(v.normal), normalize(v.tangent.xyz))) * v.tangent.w;
float3x3 rotation = float3x3(v.tangent.xyz, binormal, v.normal);
o.tangentLightDir = mul(rotation, ObjSpaceLightDir(v.vertex)).xyz;
o.tangentViewDir = mul(rotation, ObjSpaceViewDir(v.vertex)).xyz;

  之后的rotation矩阵就是从模型空间向切线空间的转换矩阵,直接通过切线空间的一组基向量来构造这个矩阵;构造好转换矩阵后,直接获取模型空间的光源方向和视角方向,通过矩阵转换得到切线空间的向量。
  进入片元计算函数后,取出光源方向以及视角方向并归一化,然后通过法线贴图解析得到法线信息,这里使用了一个Unity的自带方法来进行解析,实际上也可以自行解析。

fixed3 lightDir = normalize(i.tangentLightDir);
fixed3 viewDir = normalize(i.tangentViewDir);
float3 unpackNormal = UnpackNormal(tex2D(_BumpTex, i.uv.zw));
unpackNormal.xy *= _BumpScale;
unpackNormal.z = sqrt(1.0 - saturate(dot(unpackNormal.xy, unpackNormal.xy)));
// 自行解析法线数据
// float4 packedNormal = tex2D(_BumpMap, i.uv.zw);
// fixed3 tangentNormal;
// tangentNormal.xy = (packedNormal.xy * 2 - 1) * _BumpScale;
// tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));

  拿到了法线,光源向量,视角向量,接着只需要按照漫反射模型和Blinn光照模型计算最后渲染结果即可。
  以上方法是在切线空间中的做法,在世界空间中的做法类似,但所用的坐标转换方式不同。
  世界空间下运算,首先需要保存世界空间的转换矩阵。

struct v2f {
    float4 pos : SV_POSITION;
    float4 uv : TEXCOORD0;
    float4 TtoW0 : TEXCOORD1;
    float4 TtoW1 : TEXCOORD2;
    float4 TtoW2 : TEXCOORD3;
};

  通过分别保存三个四元组来传递跟坐标相关的信息。

v2f vert(a2v v) {
    v2f o;
    o.pos = UnityObjectToClipPos(v.vertex);
    o.uv.xy = TRANSFORM_TEX(v.texcoord, _MainTex);
    o.uv.zw = TRANSFORM_TEX(v.texcoord, _BumpTex);
    float3 worldPos = mul(unity_ObjectToWorld, v.vertex);
    fixed3 worldNormal = UnityObjectToWorldNormal(v.normal);
    fixed3 worldTangent = UnityObjectToWorldDir(v.tangent.xyz);
    fixed3 worldBinormal = cross(worldNormal, worldTangent) * v.tangent.w;
    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;
}

  注意到代码中使用了三个四元组的w分量来保存顶点的世界坐标,这个仅仅是出于节约寄存器的角度考虑,如果有需要可以将世界坐标单独储存在一个寄存器中而无需使用这种不直接的方式。
  这一次顶点坐标没有计算切线空间的三个方向向量,反而是通过计算世界空间的法线,切线和副法线,组合得出了一个转换矩阵,这也就是TtoW系列数据的来源。
  得到需要的数据后,转入片元计算函数。

fixed4 frag(v2f i) : SV_TARGET {
    float3 worldPos = float3(i.TtoW0.w, i.TtoW1.w, i.TtoW2.w);
    fixed3 lightDir = normalize(UnityWorldSpaceLightDir(worldPos));
    fixed3 viewDir = normalize(UnityWorldSpaceViewDir(worldPos));
    float3 unpackNormal = UnpackNormal(tex2D(_BumpTex, i.uv.zw));
    unpackNormal.xy *= _BumpScale;
    unpackNormal.z = sqrt(1.0 - saturate(dot(unpackNormal.xy, unpackNormal.xy)));
    unpackNormal = normalize(half3(dot(i.TtoW0.xyz, unpackNormal), dot(i.TtoW1.xyz, unpackNormal), dot(i.TtoW2.xyz, unpackNormal)));
    fixed3 albedo = tex2D(_MainTex, i.uv.xy).rgb * _Color.rgb;
    fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
    fixed3 diffuse = _LightColor0.rgb * albedo * saturate(dot(unpackNormal, lightDir));
    fixed3 halfDir = normalize(lightDir + viewDir);
    fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(saturate(dot(unpackNormal, halfDir)), _Gloss);
    return fixed4(ambient + diffuse + specular, 1.0);
}

  片元函数的一开始就将顶点世界坐标取了出来,然后根据它来获取了世界空间里的光源方向和视角方向;取得法线信息的方式和切线空间下的方法一样,但最后需要进行一次转化,代码中将整个转化过程浓缩到了一句代码里,但实际上可以将其展开。

half x = dot(i.TtoW0.xyz, unpackNormal);
half y = dot(i.TtoW1.xyz, unpackNormal);
half z = dot(i.TtoW2.xyz, unpackNormal)
unpackNormal = normalize(half3(x, y, z));

  这样看起来会直观一些,这一步的主要作用就是将获取到的法线信息从切线空间下转换到世界空间下,因为已经保存了这个转换所需矩阵的三个分量,因此直接利用进行计算即可。
  通过这样的流程,法线贴图就被应用到了模型上,由于法线信息随着贴图坐标的变化而变化,在模型的表面渲染中实际上就造成了凹凸不平的感觉。
  最简单的法线贴图代码解析到这里,实际使用中法线贴图会有很多不同的表现方式,甚至也会有很多不同的计算技巧,但无论怎么变,法线贴图的原理都不会改变的。
  后面会介绍一种有些特殊的Shader渲染方式以及和透明物体相关的Shader代码解析。

猜你喜欢

转载自blog.csdn.net/soul900524/article/details/79472178