BUMP图改进凹凸贴图映射
在不改变模型实际顶点结构的前提下,通过修改光照计算或采样方式,让表面看起来有凹凸细节。
法线映射 - 基础版欺骗
-
原理:直接修改法线方向。
-
模型原本的法线是插值得到的平滑法线。
-
我们从一张特殊的纹理(法线贴图)中采样,获取一个存储在切线空间中的新法线方向。
-
使用 TBN 矩阵 将这个切线空间法线转换到世界空间。
-
使用这个修改后的法线代替原始法线进行光照计算(如计算
dot(N, L)
)。
-
-
效果:通过改变光线的反射方式,模拟出凹凸感。只有光影变化,没有轮廓变化。从侧面看或物体边缘,破绽会暴露(依然是平滑的)。
-
计算代价:最低。主要开销是一次纹理采样和一次矩阵乘法(3次点积)。
切线空间
法线的存储一般都会放在模型的切线空间中。
切线空间是以物体表面的切线,副切线和法线组成的几何空间。
当我们计算的时候需要把光照的运算都放到统一坐标系下。读取法线贴图出来的法线是切线空间法线,需要世界空间转切线空间的矩阵,或者是逆矩阵。将向量统一到同一坐标系下。
// 转换法线、切线和副切线到世界空间o.worldNormal = normalize(mul(unity_ObjectToWorld, float4(v.normal, 0.0)).xyz);o.worldTangent = normalize(mul(unity_ObjectToWorld, float4(v.tangent.xyz, 0.0)).xyz);o.worldBinormal = normalize(cross(o.worldNormal, o.worldTangent) * v.tangent.w);...// 从法线贴图获取切线空间法线float3 tangentNormal = UnpackNormal(tex2D(_NormalMap, i.uv));// 创建切线空间到世界空间的转换矩阵float3x3 tangentToWorld = float3x3(i.worldTangent,i.worldBinormal,i.worldNormal);// 将法线从切线空间转换到世界空间float3 worldNormal = normalize(mul(tangentToWorld, tangentNormal));
Shader "Custom/NormalMap" {Properties {_MainTex ("Albedo (RGB)", 2D) = "white" {}_NormalMap ("Normal Map", 2D) = "bump" {}_Glossiness ("Smoothness", Range(0,1)) = 0.5_Metallic ("Metallic", Range(0,1)) = 0.0}SubShader {Tags { "RenderType"="Opaque" }LOD 200Pass {Tags { "LightMode" = "ForwardBase" }CGPROGRAM#pragma vertex vert#pragma fragment frag#include "UnityCG.cginc"#include "Lighting.cginc"#include "AutoLight.cginc"struct appdata {float4 vertex : POSITION;float3 normal : NORMAL;float4 tangent : TANGENT;float2 uv : TEXCOORD0;};struct v2f {float2 uv : TEXCOORD0;float4 pos : SV_POSITION;float3 worldPos : TEXCOORD1;float3 worldNormal : TEXCOORD2;float3 worldTangent : TEXCOORD3;float3 worldBinormal : TEXCOORD4;};sampler2D _MainTex;float4 _MainTex_ST;sampler2D _NormalMap;float _Glossiness;float _Metallic;v2f vert (appdata v) {v2f o;o.pos = UnityObjectToClipPos(v.vertex);o.uv = TRANSFORM_TEX(v.uv, _MainTex);o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;// 转换法线、切线和副切线到世界空间o.worldNormal = normalize(mul(unity_ObjectToWorld, float4(v.normal, 0.0)).xyz);o.worldTangent = normalize(mul(unity_ObjectToWorld, float4(v.tangent.xyz, 0.0)).xyz);o.worldBinormal = normalize(cross(o.worldNormal, o.worldTangent) * v.tangent.w);return o;}fixed4 frag (v2f i) : SV_Target {// 从法线贴图获取切线空间法线float3 tangentNormal = UnpackNormal(tex2D(_NormalMap, i.uv));// 创建切线空间到世界空间的转换矩阵float3x3 tangentToWorld = float3x3(i.worldTangent,i.worldBinormal,i.worldNormal);// 将法线从切线空间转换到世界空间float3 worldNormal = normalize(mul(tangentToWorld, tangentNormal));// 获取世界空间中的光照和视图方向float3 lightDir = normalize(_WorldSpaceLightPos0.xyz);float3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos);// 计算漫反射光照float diffuse = max(0, dot(worldNormal, lightDir));// 计算镜面反射 (Blinn-Phong)float3 halfDir = normalize(lightDir + viewDir);float specular = pow(max(0, dot(worldNormal, halfDir)), _Glossiness * 128);// 获取主纹理颜色fixed4 albedo = tex2D(_MainTex, i.uv);// 计算最终颜色fixed4 col = albedo * _LightColor0 * diffuse;col += _LightColor0 * specular * _Metallic;col.rgb += UNITY_LIGHTMODEL_AMBIENT.rgb * albedo.rgb;return col;}ENDCG}}FallBack "Diffuse"
}
视差映射 - 进阶版欺骗(“体感”欺骗)
视差映射的核心就是改变纹理坐标,用一张存储了模型信息的高度图,利用模型表面高度信息来对纹理进行偏移。
-
解决的问题:法线贴图没有视差效果(即观察角度变化时,凹凸应该看起来有位移)。
-
原理:根据视角和高度,对UV坐标进行偏移。
-
有一张高度图,存储了表面的高度信息(白色高,黑色低)。
-
在片段着色器中,根据视角方向
V
和该点的高度值h
,计算出UV应该偏移多少。
UV_Offset = V.xy * (h * _Scale); // _Scale是一个控制强度的参数
-
用偏移后的新UV去采样法线贴图和颜色贴图。
-
-
效果:比法线贴图更具体积感,尤其是斜着看的时候,凹凸感更真实。但偏移量过大时会出现采样错误(看起来像扭曲)。
-
计算代价:中等。比法线贴图多一次高度图采样和简单的计算。
// 视差映射函数float2 ParallaxMapping(float2 texCoords, float3 viewDir){ float height = tex2D(_DepthMap, texCoords).r; float2 p = viewDir.xy / viewDir.z * (height * _HeightScale);return texCoords - p; }
视差映射通常是在切线空间下进行的,因为高度图存储的高度信息是相对于切线空间的。因此,我们需要将世界空间的视线方向转换到切线空间,然后在切线空间进行视差偏移,最后再用偏移后的纹理坐标去采样法线贴图和漫反射贴图。
在顶点着色器中计算切线空间到世界空间的矩阵,以及世界空间到切线空间的逆矩阵。在片元着色器中,将世界空间的视线方向转换到切线空间。使用切线空间的视线方向进行视差映射,得到偏移后的纹理坐标。使用偏移后的纹理坐标采样法线贴图和漫反射贴图。
v2f vert (appdata v) {v2f o;o.pos = UnityObjectToClipPos(v.vertex);o.uv = TRANSFORM_TEX(v.uv, _MainTex);o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;// 转换法线、切线和副切线到世界空间o.worldNormal = normalize(mul(unity_ObjectToWorld, float4(v.normal, 0.0)).xyz);o.worldTangent = normalize(mul(unity_ObjectToWorld, float4(v.tangent.xyz, 0.0)).xyz);o.worldBinormal = normalize(cross(o.worldNormal, o.worldTangent) * v.tangent.w);// 计算世界空间视图方向float3 worldViewDir = normalize(_WorldSpaceCameraPos.xyz - o.worldPos);// 创建世界空间到切线空间的转换矩阵float3x3 worldToTangent = float3x3(o.worldTangent,o.worldBinormal,o.worldNormal);// 将视图方向转换到切线空间o.tangentViewDir = mul(worldToTangent, worldViewDir);TRANSFER_VERTEX_TO_FRAGMENT(o);return o;}fixed4 frag (v2f i) : SV_Target {// 应用视差映射float2 parallaxUV = ParallaxMapping(i.uv, i.tangentViewDir);// 从法线贴图获取切线空间法线float3 tangentNormal = UnpackNormal(tex2D(_NormalMap, parallaxUV));// 创建切线空间到世界空间的转换矩阵float3x3 tangentToWorld = float3x3(i.worldTangent,i.worldBinormal,i.worldNormal);// 将法线从切线空间转换到世界空间float3 worldNormal = normalize(mul(tangentToWorld, tangentNormal));
陡峭视差映射 - 更逼真的欺骗
-
解决的问题:基础视差贴图在陡峭角度和较大高度差时容易穿帮。想获得更精准的效果就需要的陡峭视差映射、其实陡峭视差映射也是一个近视的解,按时更精准,并对纹理坐标偏移进行合理检查。
-
原理:分层深度追踪。
-
将高度范围(0到1)分成若干层(如10层)。
-
从最高层(1.0)或最低层(0.0)开始,沿着视角方向
V
一步步前进。 -
每一步都采样当前层的高度,并与当前点的深度进行比较。
-
当发现当前深度首次大于高度图采样的深度时(即穿过了表面),就找到了一个近似的交点。
-
用这个交点的UV坐标进行后续采样。
-
-
效果:比普通视差贴图精确得多,效果更好,尤其适合深度变化较大的表面。
-
计算代价:较高。需要多次(取决于层数)循环采样高度图,性能开销与迭代次数成正比。
// 陡峭视差映射函数
float2 SteepParallaxMapping(float2 texCoords, float3 viewDir)
{// 计算每层的深度步长float layerDepth = 1.0 / _NumLayers;// 当前层深度float currentLayerDepth = 0.0;// 每层的纹理坐标偏移量float2 P = viewDir.xy / viewDir.z * _HeightScale;float2 deltaTexCoords = P / _NumLayers;// 初始化纹理坐标和当前深度值float2 currentTexCoords = texCoords;float currentDepthMapValue = tex2Dlod(_DepthMap, float4(currentTexCoords, 0, 0)).r;// 使用for循环,最大迭代次数为_NumLayersfor (int i = 0; i < _NumLayers; i++){// 如果当前层深度已经大于深度图的值,则跳出循环if (currentLayerDepth >= currentDepthMapValue)break;// 偏移纹理坐标currentTexCoords -= deltaTexCoords;// 获取新纹理坐标处的深度值currentDepthMapValue = tex2Dlod(_DepthMap, float4(currentTexCoords, 0, 0)).r;// 增加深度currentLayerDepth += layerDepth;}// 获取碰撞前的纹理坐标(用于插值)float2 prevTexCoords = currentTexCoords + deltaTexCoords;// 获取碰撞前后的深度float afterDepth = currentDepthMapValue - currentLayerDepth;float beforeDepth = tex2Dlod(_DepthMap, float4(prevTexCoords, 0, 0)).r - (currentLayerDepth - layerDepth);// 线性插值计算权重float weight = afterDepth / (afterDepth - beforeDepth);// 插值得到最终纹理坐标float2 finalTexCoords = prevTexCoords * weight + currentTexCoords * (1.0 - weight);return finalTexCoords;
}
视差遮蔽映射 (POM) - 近乎真实的欺骗
解决的问题:陡峭视差映射虽然比基础视差映射精准,但其“分层步进”的本质决定了它仍然是一个离散化的近似解。在层数不足时,表面会呈现出“阶梯状”的层叠瑕疵。想要获得真正平滑、连续且精确的交点,并在此基础上实现自阴影效果,就需要视差遮蔽映射。
原理:分层深度追踪 + 光线精确求交 + 自阴影。
-
分层深度追踪(粗调和):
-
和陡峭视差映射一样,首先将深度范围分成若干层,沿着视角方向
V
一步步前进,找到一个穿透表面的、包含精确交点的微小区间。这一步是快速的“粗调”。
-
-
光线精确求交(精调):
-
在粗调找到的微小区间内(例如,第
n
层还在表面上方,第n+1
层已经穿透表面),不再满足于用这一层或简单的线性插值作为最终结果。 -
转而使用二分查找(Binary Search) 或其它光线追踪(Ray Marching) 技术,在这个极小的区间内进行多次迭代,精确地计算出视线射线与高度场表面的交点。这一步是精准的“精调”。
-
-
自阴影(Self-Shadowing):
-
得到精确的交点
P
后,为了极致真实,可以再从点P
向光源方向L
发射一条新的射线。 -
同样使用分层步进和精确求交的方法,检查这条新的射线是否会与自身的高度场再次相交。
-
如果会相交,则说明点
P
处于自身投射的阴影中,需要相应地减弱其漫反射光照分量。
-
效果:是目前最顶级的视差效果模拟技术。
-
极其精确的凹凸细节,消除了阶梯状瑕疵。
-
动态的、准确的自阴影,这是产生立体感和真实感最重要的因素之一。
-
在非极端角度下,视觉效果几乎可以媲美真正的几何镶嵌(Tessellation),但性能开销低得多。
计算代价:非常高。
-
它需要
粗调步数 + 精调步数
次的高度图纹理采样。例如(10层粗调 + 5次二分查找 = 15次采样/像素)。 -
自阴影功能需要再重复一次上述过程,相当于双倍的计算开销。
-
性能开销与迭代次数成正比,是各种视差技术中最耗性能的一种,通常用于主机/PC平台的重要材质(如地面、墙壁),移动端需极度优化或避免使用。
// 视差遮蔽映射函数 float2 ParallaxOcclusionMapping(float2 texCoords, float3 viewDir, out float parallaxHeight){// 计算每层的深度步长float layerDepth = 1.0 / _NumLayers;// 当前层深度float currentLayerDepth = 0.0;// 每层的纹理坐标偏移量float2 P = viewDir.xy / viewDir.z * _HeightScale;float2 deltaTexCoords = P / _NumLayers;// 初始化纹理坐标和当前深度值float2 currentTexCoords = texCoords;float currentDepthMapValue = 1.0 - tex2Dlod(_DepthMap, float4(currentTexCoords, 0, 0)).r;// 线性搜索 - 找到大致的相交区间for(int i = 0; i < _NumLayers; i++){// 如果当前层深度已经超过深度图值,提前退出循环if(currentLayerDepth >= currentDepthMapValue)break;// 偏移纹理坐标currentTexCoords -= deltaTexCoords;// 获取新纹理坐标处的深度值 (反转: 0=低, 1=高)currentDepthMapValue = 1.0 - tex2Dlod(_DepthMap, float4(currentTexCoords, 0, 0)).r;// 增加深度currentLayerDepth += layerDepth;}// 获取碰撞前的纹理坐标(用于二分查找)float2 prevTexCoords = currentTexCoords + deltaTexCoords;// 保存当前深度和前一深度用于二分查找float depthAfter = currentDepthMapValue;float depthBefore = 1.0 - tex2Dlod(_DepthMap, float4(prevTexCoords, 0, 0)).r;// 二分查找 - 精确交点定位float2 intersectTexCoords = currentTexCoords;for(int j = 0; j < _BinarySearchSteps; j++){// 计算中间点float2 midTexCoords = (prevTexCoords + currentTexCoords) * 0.5;float midDepth = 1.0 - tex2Dlod(_DepthMap, float4(midTexCoords, 0, 0)).r;// 调整搜索区间if(currentLayerDepth < midDepth){prevTexCoords = midTexCoords;depthBefore = midDepth;}else{currentTexCoords = midTexCoords;depthAfter = midDepth;}}// 计算最终权重float weight = (currentLayerDepth - depthAfter) / (depthBefore - depthAfter + 0.0001);float2 finalTexCoords = prevTexCoords * weight + currentTexCoords * (1.0 - weight);// 返回视差高度用于自阴影计算parallaxHeight = currentLayerDepth;return finalTexCoords;}
// 自阴影计算函数
float CalculateSelfShadow(float2 texCoords, float3 lightDir, float parallaxHeight)
{// 计算光线方向的偏移float2 lightOffset = lightDir.xy / lightDir.z * _HeightScale * parallaxHeight;// 采样阴影处的深度float shadowDepth = 1.0 - tex2D(_DepthMap, texCoords + lightOffset).r;// 计算阴影强度float shadow = shadowDepth < parallaxHeight ? _ShadowStrength : 1.0;return shadow;
}
优化
动态层次调整
视差遮蔽映射采用的是光线步进,当高度层次划分的越多,那么得到的结果越精确,但是随之而来的就是计算量的增大,为了优化计算量,我们可以动态的调节高度分层,避免了在所有情况下都使用最大采样次数,减少不必要的计算。
根据视角与表面法线的夹角动态调整采样层数
当视角垂直于表面时(dot(viewDir, normal) ≈ 1
),视差效果不明显,使用较少层数
当视角接近平行于表面时(dot(viewDir, normal) ≈ 0
),视差效果显著,需要更多层数
float numLayers = lerp(_MaxLayers, _MinLayers, abs(dot(float3(0, 0, 1), viewDirTS)));
Jitter(抖动采样)
RayMarching采样次数过少会导致分层伪影的问题,有很明显的分层感。一般会采用Jitter(抖动)解决。通过引入随机偏移,每个像素的采样起点随机偏移,打破采样点的规律性排列。即使使用较少的采样层数,也能获得更平滑、更自然的结果。
// 抖动的随机噪声
float RandomNoise(float2 uv)
{ uv += 1 * float2(47.0, 17.0) * 0.695;const float3 magic = float3(0.06711056, 0.00583715, 52.9829189);return frac(magic.z * frac(dot(uv, magic.xy)));
}...// 在循环前添加抖动
float layerDepth = 1.0 / numLayers;
float currentLayerDepth = layerDepth * RandomNoise(i.uv); // 添加抖动起点
位移映射 - 不再是欺骗
-
原理:真正地修改顶点的几何位置。
-
在顶点着色器或曲面细分着色器中,采样高度图。
-
直接沿着法线方向位移顶点
Vertex.xyz += Normal * (height * _Scale)
。
-
-
效果:完美。它真实地改变了模型的几何形状,因此拥有正确的轮廓、遮挡和阴影。是效果最好的技术。
-
计算代价:最高。
-
如果用在顶点着色器,需要模型本身有足够多的顶点来表现细节。
-
最佳实践:与曲面细分着色器配合使用。先通过曲面细分生成大量的新顶点,再对这些顶点进行位移。这是目前3A游戏表现超高细节的主要手段。
-