Lv.3 Unity主线:视差(Parallax Mapping)

核心公式

视差原理其实就根据视线方向来影响UV采样,那么这就需要将视线方向转换到切线空间,也就是乘上TBN矩阵就行

1
offsetUV = uv - (viewDirTS.xy / viewDirTS.z) * height * scale

ParallaxMapping_Basic

代码

简单的视差处理,性能较好但是在高差过高会出现明显断层,所以只能支持非常小的视差_ParallaxScale的值在0~5左右,乘上0.01,也就是几厘米的视差

1
2
3
4
5
6
7
8
9
10
11
12
13
_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)

基础版

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
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进行调整

1
2
3
4
//最大步进距离
float2 maxParallaxOffset = viewDirTS.xy / max(viewDirTS.z, 0.001) * _HeightScale; 
//======================
float2 currentUV = uv + maxParallaxOffset * (1.0 - _HeightReferencePlane);

锯齿和纹理模糊

GPU 如何选择 Mipmap 级别?

正常情况使用tex2D来采样

1
fixed4 color = tex2D(_MainTex, uv);
  • 自动计算导数:GPU 自动执行 ddx(uv)ddy(uv)
  • 自动选择 mipmap:根据导数大小自动选 mip level
  • 问题:因为相当于模型进行上下凹凸,POM 后导数不准确

那么影响就是导数不准确,这会导致mipmap可能会过大,导致纹理模糊和锯齿

解决办法有两种: 1.使用tex2Dlod来强制指定mipLevel,也就是跳过ddx(uv),ddy(uv),但是固定lod效果会差一些。如果你离物体很远,由于没有 Mipmap 过滤,画面会产生极强的闪烁和噪点

1
2
fixed4 color = tex2Dlod(_MainTex, float4(i.uv, 0, mipLevel));
//tex2Dlod,float4数据第三位通常为0,代表是纹理数组索引

2.使用tex2Dgrad,手动指定导数,也就是排除凹凸对影响 在某些平台(特别是移动端 GPU),ddx()/ddy() 只能对插值器(varying)计算导数,不能对函数内部的局部变量计算,也就是需要再循环外部进行dxdy的计算,不要在循环内部计算

1
2
3
4
5
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 时代)的一种经验算法。

1
2
3
4
//修改前
float2 maxParallaxOffset = viewDirTS.xy / max(viewDirTS.z, 0.001) * _HeightScale; //最大步进距离
//修改后
float2 maxParallaxOffset = viewDirTS.xy / max(viewDirTS.z + 0.42, 0.001) * _HeightScale; //最大步进距离

修改后则会好一些,但是这会牺牲一点视差强度 2.第二种方法使用一个更好的淡出系数

1
2
3
4
5
6
//倍数线性淡出,省性能
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) 时,视差开始线性向零收缩。 指数淡出系数

自阴影

因为要做到模型凹凸,自然会有遮挡,所以需要处理自阴影 原理和视线的步进分层一样,只不过换成了灯光向量了,需要转换到切线空间

1
2
half3 lightWS = normalize(_WorldSpaceLightPos0.xyz);
o.lightDirTS= mul(TBN, lightWS); 

优化版完整函数代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
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);
            }

大致效果




    Enjoy Reading This Article?

    Here are some more articles you might like to read next:

  • URP - RendererFeature :ScreenSpaceOutline
  • 平滑法线处理 - 八面体映射
  • Lv.3 Unity主线:一个简单的PBRShader
  • 理论支线:直接光漫反射与GGX高光的混合问题
  • 理论支线:PBR - 基于图像的照明( image based lighting-IBL)

  • # #