做了个小demo,场景里只有一片低模平面和一个定向光,却跑出了连绵起伏的地表、错落的岩层,以及随手一拉视角就跟着晃动的立体感。不是说好风格化地形要建高模、刷顶点或者靠地形系统铺几百层贴图吗?其实不用。在URP下面,用一张视差贴图就能把“假体积感”做到八九分,这是我在Unity里折腾风格化地形时觉得性价比最高的一招,今天把整个思路、Shader实现和踩过的坑一起聊清楚。
这套方案适合的人很明确:做风格化低模场景、想把地表细节提上去又不想堆顶点数的TA、图形程序,以及所有用URP做移动端或PC端项目的开发者。它不依赖高模,不吃GPU带宽,视觉反馈却很直接——视角变化时地面会明显“鼓起来”,石头边缘有遮挡关系,山脊转折处有挤压感,这些都是普通法线贴图给不了的。
1. 一个方案要解决的问题:为什么用视差贴图做“假地形”
1.1 地形起伏的三种常见做法对比
想在地表上表现起伏,大部分人第一反应是加顶点。Unity自带的Terrain系统可以把地形刷得高低起伏,但代价是大量三角形,而且移动端一跑就掉帧。第二种常见做法是法线贴图,通过扰动光照方向让平面看起来有凹凸感,但这东西只改变光照,不改变采样,视角一转就穿帮了,尤其在半俯视角的游戏里,地面像贴了张画皮。
第三种就是用视差贴图。简单说,它在法线贴图的基础上增加了一步视角相关的UV偏移,根据高度图的高度值去重新采样纹理,让表面看起来真的“挤”出了一层深度。URP下配合自定义Shader或者Shader Graph都能做,效果好、成本低,是目前做风格化地表表现很讨巧的方案。
三者的区别可以用一张表说明:
| 方案 | 顶点成本 | 视觉立体感 | 视角变化表现 | 实现复杂度 | 适合场景 |
|---|---|---|---|---|---|
| 网格置换 | 极高 | 真立体 | 完全正确 | 中 | 需要真实碰撞、近距离特写 |
| 法线贴图 | 零 | 低 | 几乎无变化 | 低 | 墙面、静态表面 |
| 视差贴图 | 零 | 中高 | 明显视差偏移 | 中 | 地表、岩壁、风格化地形 |
1.2 这个方案适合什么场景,不适合什么场景
我在实际项目中主要用视差贴图来处理三类内容:大面积地表底色、岩壁和崖面、以及远处山体的多层叠加。低模风格化游戏里,模型本身只有几千个面,地形就是一整块Plane或简模,但视觉上又需要地势高差,这套“假深度”方案特别合适。
但它也有明确的边界。如果你的地形需要玩家角色走上去、需要物理碰撞,那必须使用真网格,视差贴图只是在视觉层“骗”你。另外,如果要近距离怼着地面看,比如玩家蹲下来看岩缝,那视差贴图也会破功,因为它的深度是模拟出来的,在极端角度下会出现边缘拉伸穿帮。我的建议是把视差贴图用在中远距离地表和背景氛围,前景局部细节交给少量真实模型,两相结合,视觉和性能都平衡。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 视差贴图原理:先弄懂它怎么“骗眼睛”
2.1 从法线贴图说到视差映射
法线贴图做的事情是逐像素改变法线方向,让原本朝上的平面,在某些区域朝侧面偏转,从而改变光照结果。但问题来了:采样位置没变,像素还是原来的像素,光看起来像是凹凸的,实际上凹凸处并没有挡住后面的内容,视角一旦拉平,凹凸感就消失了。
视差贴图的关键突破在于它修改了采样坐标。它读取一张高度图,高度值代表该点表面的起伏程度,然后根据视线方向对UV做偏移:高处往一个方向偏,低处往另一个方向偏,模拟出“深度遮挡”的效果。
2.2 高度图与UV偏移公式
高度图里,通常白色代表高处,黑色代表低处,也可以反过来,看你用哪种约定。最基础的视差映射公式是:
hlsl复制float3 viewDir = normalize(IN.viewDirOS); // 切线空间视线方向
float height = tex2D(_HeightMap, IN.uv).r;
float2 parallaxOffset = (viewDir.xy / viewDir.z) * height * _ParallaxStrength;
IN.uv += parallaxOffset * float2(1, -1); // 具体符号取决于UV朝向
这里面最关键的是 viewDir.xy / viewDir.z。z分量越大,说明视线越直,偏移越小;z越小,说明视线越斜,偏移越大。想想你从侧面看一块石头,石头边缘会遮挡住后面的地面,这个比值就是在模拟这种透视遮挡关系。
_ParallaxStrength 是强度系数,0到0.1之间通常是安全范围。调太大会看到明显的拉伸断层,调太小又看不出效果。我在风格化地形里一般取0.02到0.05之间。
2.3 进阶玩法:视差遮蔽映射POM
这个偏学术向,如果你只想要结果,可以跳过这一段。但自从我用POM跑过一次岩壁,就再也回不去了。POM全称Parallax Occlusion Mapping,它不是做一次UV偏移就完事,而是在视线方向上做多次采样的步进:
hlsl复制float numSteps = 8;
float stepSize = 1.0 / numSteps;
float2 deltaUV = viewDir.xy / viewDir.z * _ParallaxStrength / numSteps;
float currentDepth = 1.0;
float2 currentUV = IN.uv;
float depthMapValue = 1.0 - tex2D(_HeightMap, currentUV).r;
while (currentDepth > depthMapValue) {
currentUV -= deltaUV;
currentDepth -= stepSize;
depthMapValue = 1.0 - tex2D(_HeightMap, currentUV).r;
}
IN.uv = currentUV;
加循环之后,效果明显上了一个台阶,岩石的缝隙、地表的沟壑都会有真实的相互遮挡关系。不过代价是每个像素要做多次采样,在移动端需要仔细控制步数。8步是我的经验值,再低会有条纹抖动,再高性能撑不住。
3. 素材准备:从空白项目到风格化地形资源
3.1 项目管线和URP配置
动手写Shader之前,先把项目管线搞成URP。Unity Hub新建项目时选择Universal 3D模板,或者在已有项目中通过Window -> Package Manager安装Universal RP包,然后在Project Settings里把渲染管线设置为URP Asset。
URP的资源设置里,我建议把阴影质量调成中,主光源实时阴影开着,后处理先不开——风格化地形其实更依赖美术做纹理,而不是后期调色。另外,在URP Asset的Shading Models里,如果只是做自己的自定义Shader,选Lit或者Unlit都可以,不影响手写Shader的兼容性。
3.2 生成高度图:三种方式任选其一
我做高度图从来不用一张真地形的高度数据,因为风格化地形要的是“画出来”的感觉,而不是真实自然。三种常用来源:
用Blender的噪波修改器或者Substance Designer的高度节点,生成一张灰阶图。这是最常用的方式,优点是可以控制特征大小和分布。比如大尺度山体用低频率噪声叠加,小尺度碎石用高频率细节,两层混合,效果很自然。
用Unity内置的Mathf.PerlinNoise写个编辑器工具来生成Texture2D。适合想要完全程序化、不想开外部软件的情况。优缺点都很明显:可控性强,但需要自己写一些编辑器代码,而且噪波算法容易显得有规律感。
直接拿Unity Terrain的Heightmap导出后处理。如果你已有刷好的地形,可以直接把它烘焙成高度图用在视差Shader上,实现“地形系统生成数据,视差Shader负责渲染”的分工。
我在项目里最喜欢的是第二种,写了一个简单的编辑器脚本,用Perlin噪声叠加三到四个octave,把频率从低到高排列,分别控制山体轮廓、岩石纹理、碎石颗粒,一步到位生成512x512的灰度图,存成PNG就完事。
3.3 风格化颜色纹理与法线贴图生成
风格化地形的颜色和写实地形最大的不同在于:用大色块,不用高频细节,颜色过渡尽量做成分阶式。我会在Substance或Photoshop里手绘:土黄色草地、灰绿色岩石、浅黄砂土,按高度分布配比。
法线贴图可以从高度图生成。Blender的Normal Map节点、Substance Designer的Normal节点都行,Unity里也能用一些第三方插件。生成的Normal Map放在Shader的NormalMap那一栏,但注意它的强度不能太猛,风格化场景里一般调到0.3到0.5就好,太强的法线会让地表看起来脏。
颜色纹理和高度图最好使用同一套坐标。我习惯把RGB通道存颜色,A通道存高度,这样一张贴图解决两件事,采样次数也能省一次。法线贴图单独存一张。两张图打包后放进Shader,性能很友好。
4. 在URP里实现基础视差地形Shader
4.1 手写Shader的整体框架
URP的手写Shader有一个固定的框架。很多人一开始在老版Shader里加视差代码,放进URP就全黑,就是因为没有遵守URP的Pass结构和光照函数规范。
我建议直接从URP的Lit Shader模板改起,最容易避开坑。框架大致是:
hlsl复制Shader "Custom/StylizedParallaxTerrain"
{
Properties
{
_ColorMap ("Color Map (RGB) - Height (A)", 2D) = "white" {}
_NormalMap ("Normal Map", 2D) = "bump" {}
_ParallaxStrength ("Parallax Strength", Range(0, 0.1)) = 0.04
_Steps ("Parallax Steps", Int) = 8
_ColorGradient ("Stylized Light Color", Color) = (1, 0.85, 0.6, 1)
}
SubShader
{
Tags { "RenderType" = "Opaque" "RenderPipeline" = "UniversalPipeline" }
Pass
{
Name "ForwardLit"
Tags { "LightMode" = "UniversalForward" }
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
// ... 顶点和片元函数
ENDHLSL
}
Pass
{
Name "ShadowCaster"
Tags { "LightMode" = "ShadowCaster" }
// ... 阴影投射
}
}
}
关键点在于 RenderPipeline 标签和 UniversalForward 光照模式,缺一个都可能导致Shader在URP里不生效。
4.2 核心代码:视差采样与风格化光照
片元着色器里,最先做的是视差采样,得到修正后的UV,然后再去采样颜色和法线。这一步必须在其他所有纹理采样之前完成,因为它会改变UV坐标。
hlsl复制half4 frag (Varyings IN) : SV_Target
{
// ---- 1. 切线空间视线方向 ----
float3 viewDir = GetViewDirectionTangentSpace(IN.tangentWS, IN.normalWS, IN.viewDirWS);
// ---- 2. 视差采样 ----
float height = tex2D(_ColorMap, IN.uv).a;
float2 uvOffset = ParallaxOffset(height, _ParallaxStrength, viewDir);
IN.uv += uvOffset;
// ---- 3. 采样颜色和法线 ----
half4 albedo = tex2D(_ColorMap, IN.uv);
half3 normalTS = UnpackNormal(tex2D(_NormalMap, IN.uv));
// ---- 4. 风格化漫反射 ----
float NdotL = saturate(dot(normalize(normalTS), normalize(_LightDirWS)));
float stylizedLight = smoothstep(0.2, 0.45, NdotL);
half3 finalColor = albedo.rgb * _LightColor0.rgb * stylizedLight * _ColorGradient.rgb + ambientColor;
return half4(finalColor, 1.0);
}
ParallaxOffset 是URP内置的视差偏移函数,它帮我处理了切线空间方向转换的细节,比自己写 xy / z 那套稳健,建议直接用。
风格化光照是我比较得意的部分。PBR的BRDF在风格化场景里反而显得脏,我用一个 smoothstep 把漫反射分成了两段:亮面和暗面,产生明显的卡通色阶感。这是Low Poly和Stylized美术风格中很常见的光照手法。
4.3 Shader Graph 等价实现思路
如果你不习惯手写Shader,URP的Shader Graph也能做到同样的效果,有两年Unity经验就能摸着做出来:
- 创建Unlit Shader Graph,但要在Graph Inspector里打开Depth Offset和Shadow Casting选项。
- 采样高度图(用颜色贴图的A通道)。
- 用Parallax Offset节点连接Height、View Direction和Amplitude(对应 _ParallaxStrength)。
- 把偏移后的UV接到Color和Normal的采样上。
- 用 A + B 的方式自己拼一个简单的光照模型,或者直接上Custom Function节点塞我上面的HLSL逻辑。
用Shader Graph的好处是能实时预览,缺点是节点一多就乱,后期维护不如Shader文件清爽。我个人在项目里更偏向手写,但二次开发团队如果都是美术向的TA,Shader Graph学习成本更低。
4.4 阴影、雾效与URP兼容性
视差贴图Shader最容易忽略的就是阴影和雾。URP里,阴影不是自动出现的,你要有一个ShadowCaster Pass。如果只写了ForwardLit Pass,在场景里你会看到物体没有阴影,或者阴影形状不对。
我实际踩过一个坑:地面成功显示了视差效果,但地面上的人物没有影子,排查半天发现是我的自定义Shader没有ShadowCaster Pass,灯光拿不到深度信息。解决办法是补上:
hlsl复制Pass
{
Name "ShadowCaster"
Tags { "LightMode" = "ShadowCaster" }
ZWrite On
ZTest LEqual
ColorMask 0
HLSLPROGRAM
#pragma vertex ShadowPassVertex
#pragma fragment ShadowPassFragment
#include "Packages/com.unity.render-pipelines.universal/Shaders/ShadowCasterPass.hlsl"
ENDHLSL
}
雾效方面,如果场景里开了URP的Fog,你需要在片元末尾手动混入雾色。URP里通过 InitializeInputData 和 ApplyFog 这类内置函数处理,或者在Final Color上自行插值:
hlsl复制float fogFactor = ComputeFogFactor(IN.positionWS.z);
finalColor.rgb = MixFog(finalColor.rgb, fogFactor);
5. 把地形做得“风格化”:色阶光照与多层混合
5.1 风格化光照不是PBR微调
很多人在URP里把材质参数调到Roughness 0.6、Metallic 0.1就觉得是风格化了,大错。PBR模型是物理正确的,它追求的是柔和、自然的能量传播,而风格化的核心诉求是控制和夸张。你要的光影是“干净、有层次的色块”,不是微妙的灰阶过渡。
我现在的地形Shader里,把光照模型换成了Weatherly风格的简易NdotL,再叠一层视差遮挡。效果是:阳面是一片亮色,阴面是暗色,过渡只有一条锐利的边界线,加上视差带来的深度变化,整个地表看起来立体但不油腻。
如果你想要更丰富的层次,可以在smoothstep后用两个threhold把它切成三到四段亮度带,模拟类似手绘插画的打光逻辑。这比调PBR参数有效得多。
5.2 草、岩石、沙地多层混合
真实的风格化地形不可能只靠一张贴图。我的做法是准备三张颜色贴图:草地、岩石、沙土,各有一张对应的灰度蒙版图控制分布范围,在Shader里做叠加混合。
混合的核心公式不复杂:
hlsl复制float grassMask = tex2D(_GrassMask, worldUV).r;
float rockMask = tex2D(_RockMask, worldUV).r;
float heightFactor = smoothstep(0.4, 0.7, worldHeight); // 高处岩石多
float3 albedo = lerp(_GrassColor, _RockColor, rockMask * heightFactor);
albedo = lerp(albedo, _SandColor, sandMask * (1 - heightFactor));
为了让草和岩石之间有自然的渐变,用smoothstep做过渡,而不是硬切。这里的 worldHeight 可以实时读取视差采样后的高度值,这样草会集中在低洼处,石头分布在高处,逻辑和自然地形一致,画面立刻有了“生态感”。
5.3 世界坐标UV vs 模型UV
一块大面积地形如果用模型自带的UV,你会遇到一个很头疼的问题:贴图密度不一。比如地形块A的UV可能比地形块B大,导致同样的纹理在A上被拉伸、在B上被压缩。
解决办法是用世界坐标的XZ作为采样UV。在顶点Shader里把 worldPos.xz 传给片元,用它代替模型UV:
hlsl复制float2 worldUV = IN.positionWS.xz / _TexelSize; // _TexelSize控制贴图密度
这样所有地形块的纹理密度一致,接缝问题也自然消失。如果用Shader Graph,World Position节点拆出XZ分量,除以Tiling就是同一件事。
6. 性能优化与常见问题排查实录
6.1 URP下Shader变体膨胀怎么控制
视差贴图本身不贵,贵的是Shader变体。如果你在Shader里用了Multi_compile,每个关键字都会生成一份变体,组合多了,包体和加载时间都会暴涨。我在URP里做这类Shader的经验是:
能用 #pragma shader_feature_local 就用 local,这样变体不会跨材质保存,只影响当前Shader,缩小变体集合。固定的循环步数用宏传参,不要做动态循环分支。POM的循环如果写死为8步,GPU在执行时不需要做动态分支判断,性能会稳定很多。
另一招是针对平台裁剪。地图类项目在移动端可以关闭Normal Map。通过 #if defined(_NORMALMAP) 包住法线采样代码,在URP的URP Asset里不勾选对应选项,变体数量能少三分之一。
6.2 常见Bug和排查清单
我把自己调Shader时遇到的典型问题和解决办法整理成一张清单,新手照着排除比盲猜快很多:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 地面全黑 | 缺少光照模式或Pass标签错误 | 检查LightMode是否为UniversalForward |
| 没有视差效果 | 高度图没有接到A通道,或强度为0 | 把 _ParallaxStrength 调到0.05 以上测试 |
| 视差方向反了 | UV坐标系符号问题 | 翻转xy偏移方向的符号,或调整切线贴图方向 |
| 地面有细纹抖动 | 循环步数太少 | 把_Steps从4增加到8或16 |
| 阴影缺失 | 没有ShadowCaster Pass | 补上ShadowCaster Pass |
| 贴图像素被拉伸 | 模型UV密度不一 | 改用世界坐标UV代替模型UV |
| 法线方向不对 | 法线贴图y轴翻转 | 在Shader里对normal.y取反,或检查导入设置 |
视差方向反了这是我刚开始时最容易疏忽的一点。Unity的UV坐标和很多DCC软件不一样,UV的v方向是向下的,如果直接套用Blender里习惯的公式,出来的偏移就会上下颠倒。这个问题没有标准解法,最直接的方式是把强度调到最大,故意看它往哪边偏,然后翻转对应符号。
6.3 移动端的实测建议
我在真机上跑过一个低模小镇场景,一整个地表都用的是一块大Plane加视差贴图,在骁龙865级别设备上,帧数稳定在60帧。相比之下,如果换成同等视觉密度的Tilemap叠加或者顶点置换Mesh,帧数至少掉10帧。
移动端真正需要注意的是采样次数。POM的循环在低端GPU上很吃ALU,如果你的目标设备是千元机,建议用单次偏移的简化视差,放弃遮蔽效果。我测试下来,单次偏移在地形俯视角下视觉差距并不大,但性能开销能省一半以上。
另外,贴图尺寸控制在1024x1024比较安全,整个地形只占一张1024的图,内存开销基本可以忽略。如果需要更大范围,用重复平铺代替大尺寸贴图,而不是一味提高分辨率。
6.4 与URP宣传tagline的协同
顺便说一句,URP的SRP Batcher在URP 12以后对自定义Shader支持已经很成熟了,但前提是你的Shader没有大量材质属性差异。如果你的地形Shader里用了大量 _ColorGradient 之类的曲线参数,SRP Batcher会自动跳过这些材质,性能反而会退化。
我建议把“全局统一”的参数放到GlobalShaderProperty里,或者直接在Shader里定义为常量。只有那些会产生MaterialPropertyBlock变化的属性才保留为材质属性。这样SRP Batcher能最大程度地合并DrawCall,视差地形整体渲染开销会非常理想。
做这个视差贴图地形,我把场景里的主地形降到了只有几百个三角形,视觉上却保留了一望无际的丘陵感。后面我又做了两个扩展方向:一个是把多层颜色混合的蒙版改成程序化生成,另一个是把同一套视差逻辑迁移到2D Sprite上做假3D表现。前者能让地形更加随机自然,后者则是2D横版游戏里的常用技巧。
最后分享一个在实际操作中很受用的小技巧:在做视差效果调试时,把 _ParallaxStrength 临时顶到0.1甚至0.15,如果这时候能看到地面明显的“流动性”,说明Shader工作正常。然后慢慢往回调,调到0.03到0.05之间,视觉和穿帮的平衡点就找到了。这个临界值在不同色彩场景里会有一点差异,但基准方法一直有效。这招在第一次接入视差贴图时特别省事,能直接把“没反应”和“调过头”两种情况一次排除干净。
