当我们需要给材质增加凹凸细节的时候,需要使用到凹凸或者法线贴图。
以下是一张高度图。
显然直接把高度图作为basecolor输出,无法起到体现凹凸的效果。
我们需要把读取到的高度图的信息,作为法线数据。
fixed4 col = tex2D(_HeightMap, i.uv);
float3 worldNormal = normalize(float3(0.0 ,col.r , 0.0));
这是由于标准化会将所有大于0的值全部归一化为1,所以除了贴图上为纯黑的区域外(物理意义上说,即山谷中的山谷,地板中的地板,马里亚纳中的马里亚纳级的深沟),其他部分会全部被标准化为(0, 1,0),即被标准化后,留下来的只由地板级高度的部分。
所以接下来我们在计算法线的时候,不单单只考虑当前的采样点,而把下一个偏移后的采样点的数据也考虑进来。
sampler2D _HeightMap;
//..._TexelSize:返回一个四位向量,前两位自动给出当前导入纹理的uv方向单位偏移量,后两位对应纹理的尺寸
// 256×128 纹理的返回值: (0.00390625, 0.0078125, 256, 128).
float4 _HeightMap_TexelSize;
fixed4 col = tex2D(_HeightMap, i.uv);
float2 delta = float2(_HeightMap_TexelSize.x, 0);
float h1 = tex2D(_HeightMap, i.uv);
float h2 = tex2D(_HeightMap, i.uv + delta);
float3 worldNormal = normalize(float3(1, (h2 - h1)/delta.x, 0));
当然,我们不喜欢除法,因为这往往会让精度很不可控,所以我们要微调标准化的一步。
float3 worldNormal = normalize(float3(delta.x, (h2 - h1), 0));
另我们可以通过设置一个缩放系数来控制法线的缩放程度(凹凸程度),让整体沟壑效果看起来没这么陡峭。
float3 worldNormal = normalize(float3(_sampleDelta, (h2 - h1), 0));
注意看这边出现了明显的光照偏移的情况,我们稍一排查就能发现,随着缩放系数的调整,标准化后的法线出现了明显的偏移。
所以我们要把法线方向沿y轴做一个顺时针90度的旋转。当然实际上单纯旋转并不能解决,我们使用切线空间下的高度图所带来的,法线方向会偏移的痛点,后续依然得通过切线空间转换来解决问题。
float3 worldNormal = normalize(float3((h1 - h2), _sampleDelta, 0));
接下来同时使用uv方向的采样偏移,并通过两个切线的叉乘来求解法线。
fixed4 col = tex2D(_HeightMap, i.uv);
float2 Hdelta = float2(_HeightMap_TexelSize.x * 0.5, 0);
float u1 = tex2D(_HeightMap, i.uv - Hdelta);
float u2 = tex2D(_HeightMap, i.uv + Hdelta);
float3 du = float3(_sampleDelta, (u2 - u1), 0);
float2 Vdelta = float2(0, _HeightMap_TexelSize.y * 0.5);
float v1 = tex2D(_HeightMap, i.uv - Vdelta);
float v2 = tex2D(_HeightMap, i.uv + Vdelta);
float3 dv = float3(0, (v2 - v1), _sampleDelta);
float3 worldNormal = cross(dv, du);
对于同样一张高度图,我们调整一下导入设置,就可以让其原地转生成法线贴图。
采样后直接缩放。
float3 worldNormal = tex2D(_HeightMap, i.uv) * 2 - 1;
我们会发现采样出来的法线结果不正确(右边是基于高度图采样的正确结果)。
这是由于我们由高度图转换来的法线贴图,都会自动使用DXT5nm格式进行压缩。DXT5格式仅保留了法线的x,y部分(即对应场景内的x, z部分),且保存在a,g通道中。
我们调整采样方式。
worldNormal.xz = tex2D(_NormalMap, i.uv).wy * 2 - 1;
worldNormal.y = sqrt(1 - dot(worldNormal.xz, worldNormal.xz));
加上_BumpScale作为参数控制xz值的缩放。
unity本身还提供了方便的方法可以直接调用,UnpackScaleNormal/UnpackNormal方法可以适应不同法线贴图压缩格式的差异,采样到正确的法线数值。
worldNormal = UnpackScaleNormal(tex2D(_NormalMap, i.uv), _BumpScale).xzy;
我们注意看这个函数的源码其中的条件判断部分:
我们需要增加shader target的声明,来使对应的判断生效
#pragma target 3.0
这里我们额外使用一张细节贴图(灰度图)作为额外的法线贴图,使其与原有的贴图混合,进一步增加凹凸细节。
同样的,使用导入法线贴图的配置使之转换。
worldNormal = UnpackScaleNormal(tex2D(_NormalMap, i.uv.xy), _BumpScale).xzy;
detailNormal = UnpackScaleNormal(tex2D(_NormalMap, i.uv.zw), _DetailBumpScale).xzy;
worldNormal = normalize((worldNormal + detailNormal)/2);
我们采样后直接进行混合,却发现效果并不明显。
这是因为直接相加的话,原本平坦的部分会对其他部分产生影响,从而导致整体表现变平。
所以我们这里采用一个偏导数相加的方法,去消除平面部分带来的影响。
mainNormal = UnpackScaleNormal(tex2D(_NormalMap, i.uv.xy), _BumpScale);
detailNormal = UnpackScaleNormal(tex2D(_NormalMap, i.uv.zw), _DetailBumpScale);
worldNormal = float3(mainNormal.xy/mainNormal.z + detailNormal.xy/detailNormal.z, 1);
worldNormal = normalize(worldNormal.xzy);
在bumpscale为1时,细节上的差别比较明显。
当然除法还是容易带来问题,我们直接通过乘法消除分母,并取消x,y值对应的缩放系数,即
worldNormal = float3(mainNormal.xy + detailNormal.xy, mainNormal.z * detailNormal.z);
worldNormal = normalize(worldNormal.xzy);
实际上这就是unity自带的BlendNormals方法的源码,我们也可以使用BlendNormals来实现。
很多人都知道,实际上模型除了法线信息外,还记录了每个顶点的切线信息。
接下来用一个脚本来让模型的顶点法线,切线,以及叉乘得到的副切线可视化。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class TangentSpaceVisualizer : MonoBehaviour
{
public float normalScale = 1;
// Start is called before the first frame update
void OnDrawGizmos()
{
MeshFilter filter = GetComponent<MeshFilter>();
if (filter){
Mesh mesh = filter.sharedMesh;
if (mesh){
showTangentSpace(mesh);
}
}
}
// Update is called once per frame
void showTangentSpace(Mesh mesh)
{
Vector3[] vertices = mesh.vertices;
Vector3[] normals = mesh.normals;
Vector4[] tangents = mesh.tangents;
for (int i =0; i<vertices.Length; i++){
showTangentSpace(transform.TransformPoint(vertices[i]), transform.TransformDirection(normals[i]), transform.TransformDirection(tangents[i]));
}
}
void showTangentSpace(Vector3 vertex, Vector3 normal, Vector3 tangent){
Gizmos.color = Color.green;
Gizmos.DrawLine(vertex, vertex + normal * normalScale);
Gizmos.color = Color.red;
Gizmos.DrawLine(vertex, vertex + tangent * normalScale);
Gizmos.color = Color.blue;
Vector3 bitangent = Vector3.Cross(tangent, normal);
Gizmos.DrawLine(vertex, vertex + bitangent * normalScale);
}
}
我们大可以直接使用原有的材质直接套给刚刚建立的默认球体,可以看到明显的效果错误。一方面是由于法线方向计算错误,导致diffuse光照错误,另一方面,我们可以看到球体顶部的纹理被压缩的很厉害,这是由于unity的默认球体本身的uv结构导致的。
为了正确使用法线贴图,我们需要做目前基本上任何一个初学者都知道的一步,要把根据模型自身的法线,切线来求解副切线,然后把这三个向量组成TBN矩阵,来对采样到的法线进行空间变换。
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
float3 normal : NORMAL;
float4 tangent : TANGENT;
};
struct v2f
{
float4 uv : TEXCOORD0;
float3 worldPos :TEXCOORD1;
float3 Normal : TEXCOORD2;
float4 tangent : TEXCOORD3;
float4 vertex : SV_POSITION;
};
sampler2D _NormalMap;
float4 _NormalMap_ST;
float4 _NormalMap_TexelSize;
sampler2D _Albedo;
float4 _Albedo_ST;
sampler2D _DetailNormalMap;
float4 _DetailNormalMap_ST;
float _sampleDelta, _BumpScale, _DetailBumpScale;
fixed4 _BaseColor;
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv.xy = TRANSFORM_TEX(v.uv, _NormalMap);
o.uv.zw = TRANSFORM_TEX(v.uv, _DetailNormalMap);
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
o.Normal = UnityObjectToWorldNormal(v.normal);
o.tangent = float4(UnityObjectToWorldDir(v.tangent), v.tangent.w);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
float3 tanNormal, mainNormal, detailNormal;
fixed3 col = tex2D(_Albedo, i.uv.xy) ;
mainNormal = UnpackScaleNormal(tex2D(_NormalMap, i.uv.xy), _BumpScale);
detailNormal = UnpackScaleNormal(tex2D(_NormalMap, i.uv.zw), _DetailBumpScale);
tanNormal = BlendNormals(mainNormal, detailNormal);
tanNormal = normalize(tanNormal);
float3 bitangent = -cross(i.tangent.xyz, i.Normal) * i.tangent.w;
float3x3 TBN = float3x3(i.tangent.xyz, bitangent, i.Normal);
float3 worldNormal = normalize(mul(tanNormal, TBN));
float3 LightDir = normalize(( _WorldSpaceLightPos0.xyz - i.worldPos));
fixed3 diffuse = dot(LightDir, worldNormal) * _LightColor0.xyz;
return fixed4( col * _BaseColor + diffuse, 1);
}
ENDCG
mikktSpace是unity使用是一种生成切线空间和法线的标准,名称由来是其建立者Morten Mikkelsen。
对于使用mikktspace的着色器,它会在顶点着色器中获取标准化的法线和切线向量,并完成插值计算,而非在在每一个片元中进行重新标准化。
副切线主要是通过法线和切线的叉乘求解的,并且叉乘的结果会乘以切线向量的w分量。
在进行切线的计算时,unity默认使用的是mikktspace。