核心公式
视差原理其实就根据视线方向来影响UV采样,那么这就需要将视线方向转换到切线空间,也就是乘上TBN矩阵就行
offsetUV = uv - (viewDirTS.xy / viewDirTS.z) * height * scale
ParallaxMapping_Basic
代码
简单的视差处理,性能较好但是在高差过高会出现明显断层,所以只能支持非常小的视差_ParallaxScale的值在0~5左右,乘上0.01,也就是几厘米的视差
_ParallaxScale ("Parallax Scale", Range(0, 10)) = 0.1
//=======================================================
half _ParallaxScale;
//================顶点vert处理输出viewDirTS==============
float3x3 TBN = float3x3(o.tangentWS, o.bitangentWS, o.normalWS);
float3 worldViewDir = normalize(_WorldSpaceCameraPos - o.positionWS);
o.viewDirTS = mul(TBN, worldViewDir);
//===================片元frag中处理UV=====================
//高度图可能需要1-处理
//float height = 1.0 - tex2D(_HeightTex, i.texcoord).r;
float height = tex2D(_HeightTex, i.texcoord).r;
float2 offset = i.viewDirTS.xy / i.viewDirTS.z * height * _ParallaxScale * 0.01;
float2 texcoord = i.texcoord - offset;
效果
效果大致如下

Parallax Occlusion Mapping (POM)
基础版
代码
float2 ParallaxOcclusionMapping(float2 uv, float3 viewDirTS)
{
// === 1. 动态调整采样层数 ===
float numLayers = lerp(_MaxLayers, _MinLayers, abs(viewDirTS.z));
// === 2. 计算步进参数 ===
float layerStepDistance = 1.0 / numLayers; // 步进距离
float2 deltaUV = viewDirTS.xy / viewDirTS.z * _HeightScale / numLayers; //步进UV
// === 3. 初始化 第一层数据===
float currentLayerDistance = 0.0; // 当前射线深度
float2 currentUV = uv; // 当前UV坐标
float currentHeight = 1 - tex2D(_HeightTex, currentUV).r; // 初始表面高度
[unroll(50)] // 展开循环优化(数字要≥_MaxLayers)
for(int i = 0; i < numLayers; i++)
{
// 检查是否当前位置数值是否大于当前高度值
if(currentLayerDistance >= currentHeight)
break;
// === 步进操作 ===
// 沿视线方向步进,下一层的数据,这里虽然命名是current,但是数据已经是下一层的了
currentUV -= deltaUV; //UV步进1层
currentHeight = 1 - tex2D(_HeightTex, currentUV).r; //深度值随UV步进1层
currentLayerDistance += layerStepDistance; //当前位置步进1层
}
// 步进操作前,也就是当前UV
float2 prevUV = currentUV + deltaUV;
//当前步进距离对高度值影响
//1 - tex2D(_HeightTex, prevUV).r 当前采样高度图数据
//currentLayerDistance - layerStepDistance 当前层的总步进距离
float beforeDistance = 1 - tex2D(_HeightTex, prevUV).r - (currentLayerDistance - layerStepDistance);
//下一层,总步进距离对高度值影响
float afterDistance = currentHeight - currentLayerDistance;
// 插值权重,两个端点之间的线性插值
float weight = afterDistance / (afterDistance - beforeDistance);
// 最终UV = 加权平均
// 对每一次步进的UV插值
float2 finalUV = lerp(currentUV, prevUV, weight);
return finalUV;
}
效果

需要调整的问题(优化)
视差凹凸基准平面设置
当前视差的凹凸只能向下凹陷,也就是基准面是0,如果需要上下同时凹凸,那么需要调整基准面,其实就是对起始层UV进行调整
//最大步进距离
float2 maxParallaxOffset = viewDirTS.xy / max(viewDirTS.z, 0.001) * _HeightScale;
//======================
float2 currentUV = uv + maxParallaxOffset * (1.0 - _HeightReferencePlane);
锯齿和纹理模糊
GPU 如何选择 Mipmap 级别?
正常情况使用tex2D来采样
fixed4 color = tex2D(_MainTex, uv);
- 自动计算导数:GPU 自动执行
ddx(uv)和ddy(uv) - 自动选择 mipmap:根据导数大小自动选 mip level
- 问题:因为相当于模型进行上下凹凸,POM 后导数不准确
那么影响就是导数不准确,这会导致mipmap可能会过大,导致纹理模糊和锯齿
解决办法有两种: 1.使用tex2Dlod来强制指定mipLevel,也就是跳过ddx(uv),ddy(uv),但是固定lod效果会差一些。如果你离物体很远,由于没有 Mipmap 过滤,画面会产生极强的闪烁和噪点
fixed4 color = tex2Dlod(_MainTex, float4(i.uv, 0, mipLevel));
//tex2Dlod,float4数据第三位通常为0,代表是纹理数组索引
2.使用tex2Dgrad,手动指定导数,也就是排除凹凸对影响
在某些平台(特别是移动端 GPU),ddx()/ddy() 只能对插值器(varying)计算导数,不能对函数内部的局部变量计算,也就是需要再循环外部进行dxdy的计算,不要在循环内部计算
float2 dx = ddx(uv);
float2 dy = ddy(uv);
tex2Dgrad(_MainTex, UV, dx, dy);
//=========也就是在循环中使用tex2Dgrad==========
currentHeight = 1.0 - tex2Dgrad(_HeightTex, currentUV, dx, dy).r;
视角畸变
当视角接近平行或者和平面非常近时会发生畸变

1.最简单的解决办法是对viewDirTS.z进行限制调整,也就是+0.42
+ 0.42 这个数值,它其实起源于早期游戏开发(如 CryEngine 时代)的一种经验算法。
//修改前
float2 maxParallaxOffset = viewDirTS.xy / max(viewDirTS.z, 0.001) * _HeightScale; //最大步进距离
//修改后
float2 maxParallaxOffset = viewDirTS.xy / max(viewDirTS.z + 0.42, 0.001) * _HeightScale; //最大步进距离
修改后则会好一些,但是这会牺牲一点视差强度

2.第二种方法使用一个更好的淡出系数
//倍数线性淡出,省性能
float angleFade = saturate(viewDirTS.z * 5.0);
//指数淡出,更自然
float angleFade = saturate(pow(viewDirTS.z, 0.5));
//smoothstep可尝试,效果不是很理想
float angleFade = smoothstep(0.15, 0.4, viewDirTS.z);
当视角低于 11° (1/5) 时,视差开始线性向零收缩。 指数淡出系数

自阴影
因为要做到模型凹凸,自然会有遮挡,所以需要处理自阴影

原理和视线的步进分层一样,只不过换成了灯光向量了,需要转换到切线空间
half3 lightWS = normalize(_WorldSpaceLightPos0.xyz);
o.lightDirTS= mul(TBN, lightWS);
优化版完整函数代码
float3 ParallaxOcclusionMapping(float2 uv, float3 viewDirTS, float3 lightDirTS)
{
// === 保存原始UV导数 ===
float2 dx = ddx(uv);
float2 dy = ddy(uv);
// 这个系数能保证在极低角度时视差平滑消失
//float angleFade = saturate(viewDirTS.z * 5.0);
float angleFade = saturate(pow(viewDirTS.z, 0.5));
//float angleFade = smoothstep(0.15, 0.4, viewDirTS.z);
// 截断淡出,效果较差
// float2 rawOffset = viewDirTS.xy / max(viewDirTS.z, 0.0001) * _HeightScale;
// float maxLen = _HeightScale * 2.0;
// float currentLen = length(rawOffset);
// float2 maxParallaxOffset = rawOffset * (min(currentLen, maxLen) / max(currentLen, 0.001));
// === 1. 动态调整采样层数 ===
float numLayers = lerp(_MaxLayers, _MinLayers, abs(viewDirTS.z));
// === 2. 计算步进参数 ===
float layerStepDistance = 1.0 / numLayers; // 步进距离
float2 maxParallaxOffset = viewDirTS.xy / max(viewDirTS.z, 0.001) * _HeightScale * angleFade; //最大步进距离
float2 deltaUV = maxParallaxOffset / numLayers; //步进UV
// === 3. 初始化 第一层数据===
float currentLayerDistance = 0.0; //起始层基准
float2 currentUV = uv + maxParallaxOffset * (1.0 - _HeightReferencePlane); // 当前UV坐标
//float currentHeight = 1 - tex2D(_HeightTex, currentUV).r; // 初始表面高度
float currentHeight = 1.0 - tex2Dgrad(_HeightTex, currentUV, dx, dy).r;
// 缓存上一步的数据,避免循环结束后重复采样
float prevHeight = currentHeight;
float prevLayerDistance = 0.0;
//[unroll(50)] // 展开循环优化(数字要≥_MaxLayers)
[loop] //标记循环
for(int i = 0; i < numLayers; i++)
{
// 1.检查是否当前位置数值是否大于当前高度值
if(currentLayerDistance >= currentHeight)
break;
// 2.记录旧状态
prevHeight = currentHeight;
prevLayerDistance = currentLayerDistance;
// 3.步进操作
// 沿视线方向步进,下一层的数据,这里虽然命名是current,但是数据已经是下一层的了
currentUV -= deltaUV; //UV步进1层
currentLayerDistance += layerStepDistance; //当前位置步进1层
// 4.采样新高度
//currentHeight = 1 - tex2Dlod(_HeightTex, float4(currentUV, 0, 0)).r; //深度值随UV步进1层
currentHeight = 1.0 - tex2Dgrad(_HeightTex, currentUV, dx, dy).r;
}
// 步进操作前,也就是当前UV
float2 prevUV = currentUV + deltaUV;
//当前步进距离对高度值影响
float beforeDistance = prevHeight - prevLayerDistance;
//下一层,总步进距离对高度值影响
float afterDistance = currentHeight - currentLayerDistance;
// 插值权重,两个端点之间的线性插值
float weight = afterDistance / (afterDistance - beforeDistance);
// 最终UV = 加权平均
// 对每一次步进的UV插值
float2 finalUV = lerp(currentUV, prevUV, weight);
//float finalHeight = lerp(currentLayerDistance, prevLayerDistance, weight);
// === 自阴影循环 (Soft Shadows) ===
float shadow = 1.0;
//float lightAngleFade = smoothstep(0.05, 0.25, lightDirTS.z);
float lightAngleFade = saturate(pow(lightDirTS.z, 0.5));
if(_ShadowSoftness > 0.0 && angleFade > 0.01 && lightDirTS.z > 0.0)
{
float hitDepth = 1.0 - tex2Dgrad(_HeightTex, finalUV, dx, dy).r;
if(hitDepth > 0.001)
{
// 计算完整的UV偏移方向(从交点到光源方向在UV上的投影)
float2 lightDirUV = lightDirTS.xy / max(lightDirTS.z, 0.001) * _HeightScale;
// 限制最大追踪距离,防止低角度时偏移过大
float maxOffset = _HeightScale * 2.0;
float offsetLen = length(lightDirUV);
if(offsetLen > maxOffset)
{
lightDirUV = lightDirUV / offsetLen * maxOffset;
}
// 按深度比例的总UV偏移
float2 totalUV = lightDirUV * hitDepth;
// 均匀分配到每一步
float2 stepUV = totalUV / _ShadowSteps;
float stepDepth = hitDepth / _ShadowSteps;
float currentDepth = hitDepth;
float2 currentUV = finalUV;
[loop]
for(int j = 0; j < _ShadowSteps; j++)
{
currentDepth -= stepDepth;
currentUV += stepUV;
if(currentDepth <= 0.0)
break;
float terrainDepth = 1.0 - tex2Dgrad(_HeightTex, currentUV, dx, dy).r;
if(terrainDepth < currentDepth)
{
float occlusion = currentDepth - terrainDepth;
shadow = min(shadow, 1.0 - saturate(occlusion * _ShadowSoftness));
if(shadow < 0.01)
{
shadow = 0.0;
break;
}
}
}
}
}
// 根据淡出系数混合阴影
shadow = lerp(1.0, shadow, angleFade * lightAngleFade);
// 根据淡出系数混合阴影(让阴影也随视角淡出)
//shadow = lerp(1.0, shadow, angleFade);
//return finalUV;
return float3(finalUV,shadow);
}
大致效果
