UE5视差材质与POM实战:高度图、UV偏移和性能优化
简介:这份资源是面向虚幻引擎5开发者与视觉美术方向学习者的视差材质实例工程,针对材质编辑器中「高度图驱动深度错觉」这一进阶主题,提供可直接打开运行的参考实现。包内共478个文件,以uasset材质与贴图资产为主,辅以umap示例关卡、ini工程配置、udd与dat缓存数据及uproject工程文件,另含少量ush着色器源码与json、png辅助素材,压缩包约918MB,属于体量完整、可直接对照调试的项目级素材。目前已有1127人学习下载,适合具备一定材质节点基础、希望突破平面贴图质感瓶颈的中级用户。
资源覆盖九宫格分区域控制与单图版两种实现路线,可观察高度图、视差偏移强度、视差映射向量与切线空间法线如何协同决定表面起伏;工程中的示例关卡便于逐项修改参数,体会强度过大导致的失真边界,并对比简化工作流与可定制程度之间的取舍。读者可据此搭建自己的凹凸砖墙、地面裂缝或浮雕面板效果,也能将其作为材质函数复用模板,嵌入既有项目验证性能开销。
1. 一块平墙看出砖缝深度:UE5 视差材质到底替我们省了什么
第一次把 UE5 视差材质贴到一面只有一个面的墙上时,我心里是打鼓的:法线贴图给了它明暗,看着还行,可一蹲下去、一走近,砖缝立刻变回一张贴纸。视差材质解决的就是这件事——用一张高度图在像素级别把 UV 偏移掉,让平面上不同部位“假装”处在不同深度,镜头移动时砖缝会跟着视线错开。它适合建筑内景、地砖、砖墙、金属格栅这类大面积、视角又贴近的东西,不想为了几厘米厚度去堆面数,这是最划算的解法。这套虚幻5 材质方案真正难的从来不是节点长什么样,而是高度图有没有做对、步进搜索有没有收敛。
2. 拆开视差的数学:UV 偏移、步进搜索与高度图的三个前提
2.1 三种做法摆一起:BumpOffset、单步视差与 POM 的边界
先把可选路径列清楚,不然很容易一上来就冲着最贵的那条路走。
| 方案 | 高度采样次数 | 会不会遮挡 | 典型用途 | 常见翻车点 |
|---|---|---|---|---|
| BumpOffset 节点 | 1 | 不会 | 远景墙面、移动端、次要物件 | 高度大的地方 UV 直接串到隔壁砖 |
| 单步视差(自写) | 1 | 不会 | 中景、砖缝浅的墙面 | 掠射角下整片糊 |
| POM 步进 + 二分 | 8~32 + 3~5 | 会(靠深度搜索) | 主角级墙面、地砖、铭牌 | 步数不够出层状条纹 |
| 视差 + Pixel Depth Offset | 同上 | 会,且能写深度 | 需要和角色、雾、接触阴影交互 | 偏移量给大了穿模 |
| 真几何 / 置换 | 0 | 真遮挡 | 特写、需要轮廓 | 面数、内存直接爆炸 |
UE5 里硬件细分被拿掉了,想靠细分把平面“拱”出厚度这条路已经走不通,剩下的现实选择基本就是 POM 加深度写入。所以选型的关键不是“哪个更准”,而是“这块面在屏幕上占多大”。占屏超过四分之一、镜头会贴脸看的,用 POM;只在走廊尽头晃一下的,BumpOffset 足够,不要浪费采样。
引擎自带一个ParallaxOcclusionMapping节点,在材质编辑器里直接搜名字就能找到,暴露了 Steps、Min Steps、Max Steps、Height Ratio 这些参数,输出偏移后的 UV。它的质量够用,但我一般还是自己写一遍 Custom 节点,原因是:自带节点的步数是常量输入,我想按视角动态调;另外自己写才能顺手把命中高度吐出来喂给 Pixel Depth Offset。
2.2 高度图不是灰度随便画:通道、sRGB 与压缩设置
视差效果有八成问题出在高度图本身,节点基本是无辜的。
约定先统一:白(1.0)代表最高、离观察者最近,黑(0.0)代表最深的凹槽。这条约定跟引擎自带节点一致,反了也不要紧,把 UV 偏移的符号取反,或者把高度值1 - h一下就行——但必须全项目统一,不然一半材质凸、一半材质凹,返工很痛苦。
导入设置有几点是硬要求。sRGB 一定要关掉,高度是线性数据不是颜色,勾着 sRGB 会让中间调整体偏移,砖缝的深度关系就歪了。压缩设置选 Masks 或 Grayscale,别用默认的 DXT1/DXT5,块压缩在灰度渐变上会产生阶梯,步进搜索走到这些阶梯上会抖出一条条亮线,这个现象我第一次遇到时盯着代码看了两个小时。8 位灰度只有 256 级,步数上到 16 以上就能看出台阶,条件允许就用 16 位灰度导入。
还有一条容易被忽略:非平铺的单张装饰板、铭牌、浮雕,高度图四周必须留一圈压到 0 的边框,宽度大概 4~8 像素。因为 UV 偏移会把采样点推出 [0,1],一推出界就采到对面的像素,边缘会被拉出一条“果冻”似的色带。平铺的砖墙反而没这个问题,偏移到隔壁 tile 本来就是正确的行为。
提示:从法线贴图反推高度图这件事,能不做就不做。法线是梯度信息,积分回来会累积误差,做出来的高度图中间总是鼓一块,砖缝的锐利边缘全丢了。要么让美术直接出灰度高度,要么用能导出高度的素材。
2.3 手写一次 UV 偏移:把 ViewDir 转到切线空间
视差的本质是一句话:观察方向越斜,同一个像素对应的高度面偏移越多。所以第一步是把相机方向转到切线空间——因为 UV 就活在切线空间里,世界空间的偏移量跟 UV 没有可比性。
做法是在材质里放一个Transform节点,输入CameraVectorWS,Source 选 World Space、Destination 选 Tangent Space,切线基由VertexNormalWS、VertexTangentWS、VertexBinormalWS三个节点提供。输出就是一个 xyz 的切线空间视线方向,习惯上叫 ViewDirTS,其中 z 分量表示“有多正对着看”。
拿到方向之后,单步视差的偏移量可以这么算:
// Inputs: UV (float2), HeightMap (Texture2DObject), ViewDirTS (float3), HeightRatio (float) // 输出:偏移后的 UV float3 V = normalize(ViewDirTS); // 掠射角保护:V.z 接近 0 时偏移量会指数级放大,必须夹住 float vz = max(abs(V.z), 0.08); // 采样高度,LOD 固定 0,避免 mip 把高度抹平 float h = Texture2DSampleLevel(HeightMap, HeightMapSampler, UV, 0).r; // 负号决定凸还是凹,反了就把这里取正 float2 offset = -(V.xy / vz) * HeightRatio * h; return UV + offset;HeightRatio是这个材质最重要的手感参数,它把“高度 0~1”映射成 UV 空间里的偏移比例。砖墙常用 0.02~0.05,地砖 0.03~0.08,浮雕铭牌可以到 0.1。它跟物体的实际尺寸没有直接关系,所以换一个模型就得重调,这也是我在做材质实例时一定把它暴露出来的原因。
把CameraVectorWS转过切线空间之后你会发现,平放的平面和竖着的墙面,同一个 HeightRatio 看起来深度差很多——这很正常,因为平面的切线基方向不同,倾斜视角下 V.xy 的模长不一样。想要跨物件统一观感,就得按物件尺寸换算实际高度,再反推 HeightRatio,这是后面性能那章会提到的做法。
Texture2DSampleLevel(..., 0)里的 0 是硬编码 LOD,别省。高度图一旦被采到高 mip,砖缝的锐利边就被平均掉了,步进会变得又平又黏。
3. 从零搭一个可用的视差材质:参数、Custom 节点与深度写入
3.1 参数集怎么切:把美术要调的和你不想让他动得分来
参数设计比代码更容易埋雷。我一般把参数分三层:给美术天天拧的、只在材质实例里分档的、编译期决定的。最典型的错误就是把步数做成普通 Scalar 参数,结果美术在实例里拉到 32,整个项目的所有实例都按 32 步编译,性能预算当场失控。
| 参数 | 类型 | 默认 | 说明 |
|---|---|---|---|
| HeightMap | Texture | 无 | 灰度高度,sRGB 关闭、Masks 压缩 |
| HeightRatio | Scalar | 0.03 | 手感参数,允许美术在 0.01~0.1 调 |
| PdoScale | Scalar | 0.01 | 深度写入强度,按物件尺寸给 |
| StepQuality | Static Switch | Mid | 分 Low/Mid/High 三档,编译期决定步数 |
| RefineSteps | Static Switch | 3 | 二分修正次数,High 档才开 5 |
| UseParallax | Static Switch | true | 低配机型直接关掉退回法线 |
Static Switch和普通 Scalar 的区别是:前者在编译期就折叠掉分支,不同档位会编出不同的 shader;后者是运行时插值,所有实例共享最贵的那份。材质质量等级(Material Quality Level)配合Quality Switch节点也能做类似的事,运行时切r.MaterialQualityLevel就能整体降档,做多平台适配时这个比 Static Switch 更好用,因为不用给每个材质单独做变体。
材质域记得设成 Surface,混合模式 Opaque,着色模型用默认的 Default Lit 就行。视差不需要特殊的光照模型,它只改 UV 和深度,跟光照管线是解耦的。
3.2 POM 的 Custom 节点写法:步进 + 二分修正
单步偏移在掠射角会崩,因为一个像素的偏移量可能横跨好几条砖缝,采样点直接跳到错误的位置上。POM 的做法是从高度 1 开始,沿着视线方向一步一步往下走,每一步采样一次当前 UV 的高度,一旦采样到的高度大于等于当前射线高度,就说明撞到表面了,停。
// Inputs: UV (float2), HeightMap (Texture2DObject), ViewDirTS (float3), // HeightRatio (float), Steps (float), RefineSteps (float) float2 uv0 = UV; float3 V = normalize(ViewDirTS); float vz = max(abs(V.z), 0.08); float2 maxOffset = -(V.xy / vz) * HeightRatio; float steps = clamp(Steps, 4.0, 32.0); float stepSize = 1.0 / steps; float2 stepUV = maxOffset * stepSize; // 射线从顶端往下走 float rayH = 1.0; float2 curUV = uv0; float2 prevUV = uv0; float prevRayH = 1.0; [loop] for (int i = 0; i < 32; i++) { if (i >= (int)steps) break; // LOD 固定 0,防止 mip 把砖缝抹平 float h = Texture2DSampleLevel(HeightMap, HeightMapSampler, curUV, 0).r; // 撞上了:采样高度 >= 射线高度 if (h >= rayH) break; prevUV = curUV; prevRayH = rayH; rayH -= stepSize; curUV += stepUV; } // 二分修正:在最后一段里逼近交点,3~5 次就够 [loop] for (int j = 0; j < 8; j++) { if (j >= (int)RefineSteps) break; float2 midUV = (curUV + prevUV) * 0.5; float midRayH = (rayH + prevRayH) * 0.5; float h = Texture2DSampleLevel(HeightMap, HeightMapSampler, midUV, 0).r; if (h >= midRayH) { curUV = midUV; rayH = midRayH; } else { prevUV = midUV; prevRayH = midRayH; } } return curUV;几个细节值得单独说。循环上限我写成常量 32,再用if (i >= steps) break提前跳出,而不是写成i < steps的动态循环——动态循环在部分平台上会被编译器强制展开成 32 份,指令数直接翻倍,编译时间也跟着涨,这个坑我在一个开放世界项目里踩得很实。[loop]属性是告诉编译器别自动展开,配合 break 用是安全的。
maxOffset里的负号跟高度图的约定绑定:高度图白为高、且相机在物体正前方时,UV 应该往视线反方向偏。第一次调的时候建议随便找一张砖墙,把HeightRatio拉到 0.2,肉眼确认凹凸方向,然后把符号固定下来,别再动。
二分修正那几行看起来很像玄学,其实就是在“最后一次没撞上的 UV”和“撞上的 UV”之间取中点,重采样判断交点在哪一半,重复几次误差就缩到 1/2^n。RefineSteps 给 3 就够,给 5 肉眼几乎看不出差别,但采样次数多了 60%。
return出来的 UV 拿去接所有需要视差的贴图:BaseColor、Normal、Roughness。注意 Normal 贴图一般也要跟着偏移,不然光影和凹凸对不上,砖缝会亮得不自然。
3.3 接上 Pixel Depth Offset 与阴影:让轮廓不再“贴纸”
只改 UV 的视差有个致命伤:深度缓冲里这面墙仍然是平的。结果是角色贴墙走过去,脚下该有的遮挡阴影没有;雾气和半透明物体穿过砖缝时也没有正确的穿插关系。解法是把“视差把表面推远了多远”写进Pixel Depth Offset引脚。
命中的高度是现成的:步进结束时rayH就是交点高度,1 - rayH大致就是从顶面往下陷进去的比例。乘一个按物件尺寸给的标量,接到 PDO:
// 接在上面 POM 代码的返回处,多输出一个 float // PdoScale 单位近似为世界单位下的最大凹陷深度 float depth = (1.0 - rayH) * PdoScale; return depth;PdoScale在米级砖墙上通常 0.005~0.02,超过 0.05 就会开始穿模,角色站在墙前会被“吸”进去一截。PDO 的代价不小,它会让深度预pass和 DXR 的命中判断都多一层,所以只在主角级材质上开。
阴影这块,Shadow Pass Switch节点是后悔药。阴影 pass 里材质会用一套降级的计算,如果你在里面也跑 32 步,阴影贴图的渲染成本会翻好几倍;更糟的是偏移后的 UV 让阴影深度对不上,自阴影出现噪点。做法是Shadow Pass Switch的 Default 接完整的 POM 结果,Shadow 分支接原始 UV 或者便宜的单步偏移。
Nanite 网格上还有一层额外注意:PDO 生效,但基于距离场的 AO 和间接光对深度偏移不敏感,砖缝里该黑的地方不会自己变黑。我一般在 BaseColor 里叠一张 AO 贴图,或者把高度值直接乘进基础色做压暗,视觉上补回来。
3.4 性能开关:步数、LOD 与移动端降级
POM 的开销是逐像素的,而且跟屏幕占比成正比。16 步加 3 次二分就是 19 次纹理采样,一面铺满屏幕的地板,光视差就能吃掉一多半的 base pass 时间。
分档我一般这么给:近景主角材质 High(24 步 + 3 次二分),中景 Mid(12 步 + 2 次),远景和移动端 Low(4 步或直接用 BumpOffset)。三档通过Static Switch编成三个变体,由Quality Switch在运行时按r.MaterialQualityLevel挑,打包时按平台设默认值。
移动端还有一条硬限制:部分低端 GPU 对循环内的纹理采样支持不好,Custom 节点会编译失败或者退化成单次采样。稳妥做法是用FeatureLevelSwitch节点,ES3.1 分支直接输出 BumpOffset 的结果,SM5 分支才走 POM。同一个材质两个分支,视觉上有差异但不会崩。
4. 避坑清单:视差翻车最多的五个现场
4.1 边缘拉出“果冻”色带
现象:一块非平铺的装饰铭牌,四周边缘被拉出放射状的彩色条纹,镜头一动条纹跟着动。
原因:UV 偏移把采样点推出了 [0,1],纹理寻址是 Wrap,于是采到了对面边缘的像素;如果高度图边缘不是 0,偏移量在边缘处还是最大值,拉得更狠。
解决:高度图四周留 4~8 像素压到 0 的边框;在 Custom 节点里对输出 UV 做clamp(curUV, 0.001, 0.999);HeightRatio 在边缘区域用顶点色或距离场做一个衰减。三条一起上最稳,只做第一条往往还不够。
4.2 掠射角糊成一团或者满屏噪点
现象:正对着墙看很干净,一贴墙侧着看,砖缝变成一片糊,偶尔还闪出噪点。
原因:V.xy / V.z在 V.z 趋近 0 时数值爆炸,偏移量一步跨过好几条砖缝,步进搜索直接跳过真实交点;再加上高度图被采到高 mip,边缘糊掉,交点判断在相邻两像素间跳变,就是你看到的噪点。
解决:max(abs(V.z), 0.08)把掠射角压住,代价是极端角度下视差变弱,但视觉上没人会盯着 88 度的墙面看;高度采样锁 LOD 0;步数按视角动态加,越斜给越多步(下一章会写)。
4.3 阴影和视差对不上,砖缝里一片死黑或者完全不黑
现象:砖缝的凹凸看得出,但角色靠近时地上没有对应的遮挡阴影;或者反过来,砖缝里出现一圈圈噪点状的自阴影。
原因:前者是没接 Pixel Depth Offset,深度缓冲还是平的;后者是阴影 pass 里也跑了完整的 POM,偏移后的 UV 让阴影深度和主 pass 对不上。
解决:主角级材质接 PDO,强度按物件尺寸给 0.005~0.02;用Shadow Pass Switch把阴影分支换成原始 UV 或单步偏移;砖缝的暗部靠 AO 贴图或高度值压基础色补,别指望距离场 AO 能识别 PDO。
4.4 一进大场景就掉帧,Profile 里找不到元凶
现象:编辑器里单看材质很流畅,一进实际场景帧率掉一半,Stat GPU里 Base Pass 时间暴涨。
原因:步数做成了普通 Scalar 参数,美术在实例里拉到 32,所有实例共享最贵的编译结果;加上大面积地面 overdraw,逐像素 30 多次采样直接压垮。
解决:步数改成Static Switch或Quality Switch分档;用 Shader Complexity 视图确认红色区域是不是集中在地面和墙面上;对只在远景出现的墙面换用 BumpOffset 版本,别一套材质打天下。
4.5 编辑器里好好的,打包到移动端完全没效果
现象:PC 上预览正常,Android/iOS 打包后视差消失,或者材质直接编译报错。
原因:移动端着色器模型对循环内纹理采样支持有限,Custom 节点里的动态循环被拒绝;另外材质质量等级的默认值在移动端可能被设成 Low,Quality Switch直接走了降级分支。
解决:用FeatureLevelSwitch给 ES3.1 单独一个 BumpOffset 分支;循环上限写成常量、用 break 提前退出;打包前在移动预览渲染级别下检查一遍材质编译日志,别等出包才发现。
5. 进阶:自适应步数加多层合并,把视差压在 2ms 以内
固定 16 步的问题是:正对着看的时候,8 步就收敛了,剩下 8 步纯浪费;侧着看的时候,16 步又不够。按视角给步数是性价比最高的一刀。
// 视线越平(V.z 越小),需要的步数越多 float facing = saturate(abs(normalize(ViewDirTS).z)); float steps = lerp(28.0, 8.0, facing); // 正面 8 步,掠射 28 步 steps = floor(steps / 4.0) * 4.0; // 量化到 4 的倍数,减少变体量化这一步别省。连续变化的步数会让分支发散,同一个 warp 里不同像素跑不同次数,GPU 实际按最长的算,等于没省。取 4 的倍数之后,实际只会出现三四种组合,编译和调度都可控。
多层材质是另一个省法。砖墙和灰缝如果各做一套 POM,就是两倍的采样。常见做法是先把两张高度图合并成一张(取max,因为砖面比灰缝高),用合并图跑一次步进拿到 UV,然后两张颜色图都用这个 UV 采样,再按命中高度做个权重混合。视觉上灰缝的深度是对的,代价只多了一次纹理采样。
| 场景 | 步数策略 | 参考耗时(1080p 半屏) |
|---|---|---|
| 主角墙面,贴脸看 | 自适应 8~28 + 3 次二分 | 1.2~1.8 ms |
| 中景地砖 | 固定 12 + 2 次二分 | 0.6~0.9 ms |
| 远景墙体 | BumpOffset 单步 | 0.1 ms 以内 |
表里的数字是某台开发机上的量级,别当基准,只用来判断“值不值得开”。真要卡预算,还是看Stat GPU和 Shader Complexity 的实际颜色。
验证方法很简单:做个摄像机沿墙平移的关卡,看砖缝边缘有没有“游动”和跳变;再把HeightRatio从 0 拉到 0.2,如果边缘拉伸先崩,说明高度图边框没处理好;最后把r.MaterialQualityLevel依次切 0/1/2,确认三档都编得出来、观感差异能接受。
血泪经验是:我现在的习惯是任何视差材质在交给美术之前,强制走一遍“高度图边框检查 + 掠射角截图 + 移动端预览编译”这三步,少一步都要在后面返工。希望帮到你。
本文还有配套的精品资源,点击获取