【Unity Shader URP】视差贴图 实战教程

文章目录

    • [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. 使用方法

  1. 新建一个 .shader 文件,命名 Parallax_URP.shader,粘贴上面代码并保存。
  2. 准备三张贴图:
    • Albedo:颜色贴图(如砖墙颜色)。
    • Normal :法线贴图,导入时 Texture Type 设为 Normal map
    • Height :高度图(灰度图,白色 = 凸起,黑色 = 凹陷)。导入时 sRGB 关闭。
  3. 新建材质,Shader 选择 Custom/Parallax_URP
  4. 在 Inspector 中:
    • 拖入三张贴图。
    • Parallax Scale0.02 起调,砖墙类一般 0.03~0.06,过大会出现 UV 拉花
    • Normal Strength 调到 1Smoothness 砖墙建议 0.2~0.4
  5. 在场景里建一个 QuadPlane,挂上材质。模型必须有正确的切线 ------Unity 自带的 Quad/Plane/Cube 都有,自定义模型要在导入时勾选 Calculate Tangents
  6. 运行 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 选 DefaultsRGB (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 在远处直接走纯法线版本,完全跳过视差采样。
相关推荐
ZC跨境爬虫2 小时前
跟着 MDN 学 HTML day_37:(深入掌握 CustomEvent 自定义事件接口)
前端·javascript·ui·html·音视频
CG_MAGIC4 小时前
3ds Max FloorGenerator 插件:快速生成地板木纹
3d·贴图·uv·建模教程·渲云渲染
UXbot10 小时前
独立设计师UI设计工具推荐(2026):支持AI原型生成与代码导出的5款工具全面评价
前端·人工智能·低代码·ui·交互·产品经理·web app
hele_two14 小时前
VS Code + CMake 调用 SDL2 & SDL2_image 完整编译教程(Windows 平台)
c++·windows·vscode·图形渲染
hele_two15 小时前
SDL2高效画实心圆的算法(一)
c++·算法·图形渲染
小清兔16 小时前
Addressable的设置打包流程
笔记·游戏·unity·c#
代码的小搬运工17 小时前
UITableView
开发语言·ui·ios·objective-c
ZC跨境爬虫18 小时前
跟着 MDN 学 HTML day_33:(Attr 接口与属性节点的深入理解)
前端·javascript·ui·html·音视频·html5
3D霸霸18 小时前
Sourcetree 拉取新工程
数据仓库·unity