文章目录
-
- [0. 效果预览](#0. 效果预览)
- [1. 原理简述](#1. 原理简述)
- [2. 功能点](#2. 功能点)
- [3. 完整 Shader(可直接用)](#3. 完整 Shader(可直接用))
- [4. 使用方法](#4. 使用方法)
- [5. 参数说明](#5. 参数说明)
- [6. 变体与扩展](#6. 变体与扩展)
-
- [变体 1:Steep Parallax(陡峭视差,分层采样)](#变体 1:Steep Parallax(陡峭视差,分层采样))
- [变体 2:Parallax Occlusion Mapping(POM,带二分插值)](#变体 2:Parallax Occlusion Mapping(POM,带二分插值))
- [变体 3:高度图自激活(无 Heightmap 时退化)](#变体 3:高度图自激活(无 Heightmap 时退化))
- [7. 常见问题](#7. 常见问题)
- [8. 性能建议](#8. 性能建议)
0. 效果预览

视差贴图(Parallax Mapping)是法线贴图的"加强版":法线只骗光照,视差还会骗"形状"。视角一斜,墙面砖缝、地砖纹路立刻有了真实的凹凸深度感,常用于砖墙、地砖、岩石、皮革等需要"伪 3D"细节的表面。
1. 原理简述
视差贴图的本质:用一张高度图,把采样 UV 沿着切线空间下的视线方向"推"一段,让平面看起来像有起伏。
法线贴图改的是"法线方向",光照算出来变了,但几何还是平的------视角越斜越穿帮。视差贴图换个思路:既然真正的凸起会让你"从侧面看到不同位置的纹理",那就直接把采样的 UV 偏移到那个"应该看到的位置"。
最简单的偏移公式(Offset Limiting,本文主 Pass 实现):
hlsl
// h: 当前点的高度 [0,1]
// viewDirTS: 切线空间下的视线方向(指向相机)
// scale: 视差强度
float2 uvOffset = viewDirTS.xy * (h * scale);
float2 uvParallax = uv - uvOffset;
几何意义:站在像素正上方时 viewDirTS.xy ≈ 0,几乎不偏移;视角越斜 viewDirTS.xy 越大,偏移越明显,凹凸感就出来了。减号是因为高度高的地方应该"挡住"后面的纹理。
更准确的版本(Parallax with Offset,把 z 也算进去):
hlsl
float2 uvOffset = viewDirTS.xy / viewDirTS.z * (h * scale);
视角极斜时 viewDirTS.z 很小,偏移会发散,所以工程上常用 Offset Limiting 版本,便宜又稳。
2. 功能点
- 用一张高度图驱动 UV 偏移,伪造表面凹凸
- 切线空间视线计算,配合任意旋转/法线
- 与法线贴图、Albedo 一起工作,光照仍由 Lambert/Blinn-Phong 提供
- 视差强度可调,0 时退化为普通法线贴图
- 可选 Steep Parallax / POM 高质量变体(见第 6 节)
- URP 管线,单 Pass,移动端友好
3. 完整 Shader(可直接用)
hlsl
Shader "Custom/Parallax_URP"
{
Properties
{
_BaseMap ("Albedo (RGB)", 2D) = "white" {} // 主贴图(颜色)
_BaseColor ("Base Color", Color) = (1,1,1,1) // 颜色叠乘
_NormalMap ("Normal Map", 2D) = "bump" {} // 法线贴图(切线空间)
_NormalStrength ("Normal Strength", Range(0,2)) = 1.0 // 法线强度
_HeightMap ("Height Map (R)", 2D) = "gray" {} // 高度图,取 R 通道
_ParallaxScale ("Parallax Scale", Range(0, 0.2)) = 0.05 // 视差强度,过大会扭曲
_Smoothness ("Smoothness", Range(0,1)) = 0.5 // 高光光滑度
_SpecColor2 ("Specular Color", Color) = (1,1,1,1) // 高光颜色(避免与内置 _SpecColor 冲突)
}
SubShader
{
Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }
LOD 200
Pass
{
Name "ForwardLit"
Tags { "LightMode"="UniversalForward" }
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma multi_compile_instancing
// URP 主光、阴影、雾等关键字(按需开启)
#pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE
#pragma multi_compile _ _SHADOWS_SOFT
#pragma multi_compile_fog
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
// ===== 顶点输入 =====
struct Attributes
{
float4 positionOS : POSITION; // 模型空间位置
float3 normalOS : NORMAL; // 模型空间法线
float4 tangentOS : TANGENT; // 模型空间切线(w 存方向手性 ±1)
float2 uv : TEXCOORD0; // 主 UV
UNITY_VERTEX_INPUT_INSTANCE_ID
};
// ===== 顶点输出(已经把 view dir 转到切线空间送给片元)=====
struct Varyings
{
float4 positionHCS : SV_POSITION; // 裁剪空间位置
float2 uv : TEXCOORD0; // 透传 UV
float3 positionWS : TEXCOORD1; // 世界空间位置(光照用)
float3 normalWS : TEXCOORD2; // 世界空间法线
float3 tangentWS : TEXCOORD3; // 世界空间切线
float3 bitangentWS : TEXCOORD4; // 世界空间副切线
float3 viewDirTS : TEXCOORD5; // 切线空间视线方向(核心!)
float fogCoord : TEXCOORD6;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap);
TEXTURE2D(_NormalMap); SAMPLER(sampler_NormalMap);
TEXTURE2D(_HeightMap); SAMPLER(sampler_HeightMap);
CBUFFER_START(UnityPerMaterial)
float4 _BaseMap_ST;
float4 _NormalMap_ST;
float4 _HeightMap_ST;
float4 _BaseColor;
float4 _SpecColor2;
float _NormalStrength;
float _ParallaxScale;
float _Smoothness;
CBUFFER_END
Varyings vert(Attributes IN)
{
Varyings OUT = (Varyings)0;
UNITY_SETUP_INSTANCE_ID(IN);
UNITY_TRANSFER_INSTANCE_ID(IN, OUT);
// 标准位置变换
VertexPositionInputs posIn = GetVertexPositionInputs(IN.positionOS.xyz);
OUT.positionHCS = posIn.positionCS;
OUT.positionWS = posIn.positionWS;
// 构建 TBN(世界空间),切线手性来自 tangent.w * unity_WorldTransformParams.w
VertexNormalInputs nrmIn = GetVertexNormalInputs(IN.normalOS, IN.tangentOS);
OUT.normalWS = nrmIn.normalWS;
OUT.tangentWS = nrmIn.tangentWS;
OUT.bitangentWS = nrmIn.bitangentWS;
// 把世界空间视线方向变换到切线空间:用 TBN 的转置(点积分量)
float3 viewDirWS = GetWorldSpaceNormalizeViewDir(posIn.positionWS);
float3x3 worldToTangent = float3x3(nrmIn.tangentWS, nrmIn.bitangentWS, nrmIn.normalWS);
OUT.viewDirTS = mul(worldToTangent, viewDirWS);
OUT.uv = TRANSFORM_TEX(IN.uv, _BaseMap);
OUT.fogCoord = ComputeFogFactor(posIn.positionCS.z);
return OUT;
}
half4 frag(Varyings IN) : SV_Target
{
UNITY_SETUP_INSTANCE_ID(IN);
// ===== 1) 视差偏移:先采样高度,再偏移 UV =====
// 注意 viewDirTS 在送入片元时已被插值,长度可能不再是 1,使用前 normalize
float3 viewDirTS = normalize(IN.viewDirTS);
float h = SAMPLE_TEXTURE2D(_HeightMap, sampler_HeightMap, IN.uv).r;
// Offset Limiting:不除 z,避免极斜视角发散
float2 uvOffset = viewDirTS.xy * (h * _ParallaxScale);
float2 uvParallax = IN.uv - uvOffset;
// ===== 2) 用偏移后的 UV 采样 Albedo / Normal =====
half4 baseCol = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uvParallax) * _BaseColor;
// 解包切线空间法线,再用 _NormalStrength 调强度
half3 normalTS = UnpackNormal(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, uvParallax));
normalTS.xy *= _NormalStrength;
normalTS = normalize(normalTS);
// ===== 3) 切线空间法线 → 世界空间法线(用 TBN)=====
float3x3 tangentToWorld = float3x3(IN.tangentWS, IN.bitangentWS, IN.normalWS);
float3 normalWS = normalize(mul(normalTS, tangentToWorld));
// ===== 4) URP 标准光照(主光 Lambert + Blinn-Phong 高光 + 阴影)=====
float4 shadowCoord = TransformWorldToShadowCoord(IN.positionWS);
Light mainLight = GetMainLight(shadowCoord);
float3 L = normalize(mainLight.direction);
float3 V = GetWorldSpaceNormalizeViewDir(IN.positionWS);
float3 H = normalize(L + V);
float NdotL = saturate(dot(normalWS, L));
float NdotH = saturate(dot(normalWS, H));
float specPow = lerp(8.0, 256.0, _Smoothness); // smoothness 映射为 Blinn-Phong 指数
float3 diffuse = baseCol.rgb * mainLight.color * NdotL * mainLight.shadowAttenuation;
float3 spec = _SpecColor2.rgb * mainLight.color * pow(NdotH, specPow) * NdotL * mainLight.shadowAttenuation;
// 简单环境项,避免阴面纯黑
float3 ambient = SampleSH(normalWS) * baseCol.rgb;
half3 col = diffuse + spec + ambient;
col = MixFog(col, IN.fogCoord);
return half4(col, 1);
}
ENDHLSL
}
// 阴影投射(用 URP 内置 Pass,避免自己实现踩坑)
UsePass "Universal Render Pipeline/Lit/ShadowCaster"
UsePass "Universal Render Pipeline/Lit/DepthOnly"
}
FallBack "Universal Render Pipeline/Lit"
}
4. 使用方法
- 新建一个
.shader文件,命名Parallax_URP.shader,粘贴上面代码并保存。 - 准备三张贴图:
- Albedo:颜色贴图(如砖墙颜色)。
- Normal :法线贴图,导入时 Texture Type 设为
Normal map。 - Height :高度图(灰度图,白色 = 凸起,黑色 = 凹陷)。导入时 sRGB 关闭。
- 新建材质,Shader 选择
Custom/Parallax_URP。 - 在 Inspector 中:
- 拖入三张贴图。
Parallax Scale从0.02起调,砖墙类一般 0.03~0.06,过大会出现 UV 拉花。Normal Strength调到1,Smoothness砖墙建议0.2~0.4。
- 在场景里建一个
Quad或Plane,挂上材质。模型必须有正确的切线 ------Unity 自带的 Quad/Plane/Cube 都有,自定义模型要在导入时勾选Calculate Tangents。 - 运行 Game 视图,转动相机或转动物体,能明显看到砖缝处的"凹陷感",平视时几乎看不出,斜视时最明显------这就对了。

5. 参数说明
| 参数 | 类型 | 范围/默认值 | 说明 |
|---|---|---|---|
_BaseMap |
2D | white | 主贴图(Albedo) |
_BaseColor |
Color | (1,1,1,1) | 与主贴图相乘的颜色 |
_NormalMap |
2D | bump | 法线贴图(切线空间,需导入为 Normal map) |
_NormalStrength |
Range | [0, 2] / 1 | 法线 XY 缩放,0 关闭法线 |
_HeightMap |
2D | gray | 高度图,取 R 通道,白凸黑凹 |
_ParallaxScale |
Range | [0, 0.2] / 0.05 | 视差强度,过大易拉花 |
_Smoothness |
Range | [0, 1] / 0.5 | 高光指数 8~256 之间映射 |
_SpecColor2 |
Color | (1,1,1,1) | 高光颜色 |
6. 变体与扩展
变体 1:Steep Parallax(陡峭视差,分层采样)
主 Pass 用的是单次偏移,斜视角下还是会"穿帮"。Steep Parallax 把高度区间切成 N 层,沿视线方向逐层步进,第一层"撞到"高度面就停下:
hlsl
// 替换 frag 中"视差偏移"那一段
float numLayers = lerp(32, 8, abs(viewDirTS.z)); // 视角越斜,层数越多
float layerDepth = 1.0 / numLayers;
float currentLayerDepth = 0.0;
float2 P = viewDirTS.xy * _ParallaxScale; // 总偏移量
float2 deltaUV = P / numLayers; // 每层偏移
float2 currentUV = IN.uv;
float currentDepthMap = SAMPLE_TEXTURE2D(_HeightMap, sampler_HeightMap, currentUV).r;
[unroll(32)]
for (int i = 0; i < 32; i++)
{
if (currentLayerDepth >= currentDepthMap) break;
currentUV -= deltaUV;
currentDepthMap = SAMPLE_TEXTURE2D(_HeightMap, sampler_HeightMap, currentUV).r;
currentLayerDepth += layerDepth;
}
float2 uvParallax = currentUV;
效果:陡峭凹凸边界更准确,但移动端开销显著上升。
变体 2:Parallax Occlusion Mapping(POM,带二分插值)
在 Steep Parallax 找到"撞到"的层之后,再用前后两层做一次线性插值,减少层间台阶感。把上面循环结束后加:
hlsl
float2 prevUV = currentUV + deltaUV;
float afterDepth = currentDepthMap - currentLayerDepth;
float beforeDepth = SAMPLE_TEXTURE2D(_HeightMap, sampler_HeightMap, prevUV).r - currentLayerDepth + layerDepth;
float weight = afterDepth / (afterDepth - beforeDepth);
float2 uvParallax = lerp(currentUV, prevUV, weight);
效果:边缘平滑,效果接近曲面细分但开销低得多,是 AAA 项目常用方案。
变体 3:高度图自激活(无 Heightmap 时退化)
当材质没拖 Heightmap 时(采样默认 gray = 0.5),偏移会有"全图整体平移"的伪影。加一行判断让它自然退化:
hlsl
float h = SAMPLE_TEXTURE2D(_HeightMap, sampler_HeightMap, IN.uv).r - 0.5; // 中性化
float2 uvOffset = viewDirTS.xy * (h * _ParallaxScale);
把高度图视为"中性 = 0.5",凸起为正,凹陷为负,没有高度图时整体不偏移。
7. 常见问题
Q: 看起来跟普通法线贴图差不多,没有"立体感"?
A: 三个排查点:① Parallax Scale 太小,从 0.05 起调;② 视角太正,视差只在斜视角下明显,转转相机;③ 模型没有切线(Plane 自带,自定义模型导入时勾 Calculate Tangents)。
Q: 边缘出现明显的拉花/扭曲?
A: Parallax Scale 调太大了。视差贴图本质是"骗",强度超过 ~0.1 就会拉花。要更强的凹凸请上 Steep Parallax / POM 变体。
Q: 旋转物体后视差方向不对?
A: 切线空间没对齐。检查模型是否有正确的 tangent,以及 mul(worldToTangent, viewDirWS) 的乘法顺序------TBN 是行向量矩阵时用 mul(M, v) 才是世界 → 切线变换,反了就会错。
Q: 高度图导入后看着发蓝/有彩色,对结果有影响吗?
A: 高度图导入要 关闭 sRGB ,否则会经过 gamma 校正,凹凸幅度变形。Texture Type 选 Default,sRGB (Color Texture) 取消勾选。
Q: 用了 POM 之后帧率掉得厉害?
A: POM 在每个像素都做循环采样,移动端不友好。优化方案:① 仅近距离开 POM,远处退化为 Offset Limiting;② 减少最大层数;③ 限制只在特定 Material 上用(地面、特写道具)。
8. 性能建议
- 首选 Offset Limiting:本文主 Pass 实现只多一次纹理采样和一次 mad,移动端基本免费。
- POM 用 LOD:用相机距离做插值,远处用主 Pass 的简单偏移,近距离才走 POM。
- 高度图分辨率 :
256~512在大部分情况下够用,砖墙类无需 1024,能省带宽。 - 复用通道:把高度图打包到 NormalMap 的 A 通道(DXT5nm 格式),少一张贴图采样。
- 平面才需要视差:Quad/Plane/Wall 这种几何上"一马平川"的物体收益最大;本身就是高模/曲面体(角色、道具)建议直接上法线贴图,视差收益小且容易拉花。
- 远距离关闭:通过 LOD Group 或 Shader Variant 在远处直接走纯法线版本,完全跳过视差采样。