ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

基于高度的权重混合:地形材质过渡不糊的Shader实现

2026/10/5 8:12:08 拓冰建站 浏览量
基于高度的权重混合:地形材质过渡不糊的Shader实现 写地形材质混合的shader时我踩过最深的坑就是“过渡糊成一团”。草地到岩石、砂土到雪地用普通的lerp按mask混合远看还行稍微走近一点就露馅两个材质像被人用画笔搅在一起完全没有自然界那种“一层压一层”的沉积感。后来换成基于高度的权重混合height-based weight blending过渡一下子干净了山脊和悬崖的边缘甚至能看出明显的“地层”层次。这篇就把这个技巧从原理到实现完整拆一遍包括能直接抄的Unity Shader代码、关键参数怎么调以及我调过几十次之后总结出来的一堆经验和坑。1. 为什么需要基于高度的权重混合1.1 传统纹理混合方案的“糊感”从哪来最常见的纹理混合做法是拿一张mask贴图或者顶点色当权重直接lerp(texA, texB, mask)。mask是0.2就混合20%的B和80%的A。听起来没毛病但问题在于这种线性插值在过渡带里永远只是两个颜色的“均值渐变”没有任何材质间的遮挡关系。你想象一下一堵岩壁脚下长了一圈苔藓苔藓不是均匀地渗进岩石里而是沿着岩石缝隙、凹陷处堆积起来高处几乎没有。用普通lerp模拟无论mask画得多精细最后都是颜色层面的晕染缺少“苔藓有厚度、会盖住岩石”的体积感。还有人会加上坡度作为混合依据比如陡峭的地方显示岩石平缓的地方显示草皮。坡度混合能解决一部分问题但它的过渡带同样受限于坡度的连续变化遇到“角度平缓但实际有落差”的地形比如悬崖顶上的平台坡度算出来的权重就很尴尬结果就是该露岩石的地方露不出来不该露的地方全是石头。还有一类做法是噪声扰动mask边缘让过渡看起来不规则。这个只能治标因为噪声只改变了mask的空间分布并没有改变“混合是在颜色空间进行”的本质。材质之间依然没有谁压过谁的关系。1.2 高度混合的核心直觉谁高谁覆盖基于高度的权重混合思路完全换了一个方向。它不再直接拿mask去lerp颜色而是引入每张贴图自己的“高度值”height在过渡区域比较两边的材质高度谁的表面更高谁就覆盖谁。打个比方刚落下的雪不是均匀地和地面混在一起而是先在岩石顶部、树枝上堆积成一层雪厚的地方完全盖住下面的岩石雪薄的地方还能隐约透出底色。这个“厚度”就是高度值雪比岩石高雪就压过岩石雪融化了变薄岩石重新露出来。高度混合做的就是这件事。落到shader层面就是两个输入原始权重比如mask决定的倾向性和高度差heightA - heightB。两个材质交界的区域里高度差越大的一方在混合结果里占比越高高度差接近0时又退回到原始权重决定的结果。这套思路不仅适用于地形任何需要表现“材质层叠感”的场景都好用比如建筑上的污渍、路面裂缝中的泥土、角色皮肤上的伤痕边缘。它解决的终极问题是让材质过渡看起来是“物理遮挡”而不是“颜色晕染”。2. 核心原理解析高度数据与混合公式2.1 高度数据从哪来贴图的哪些通道能当高度用高度权重混合的第一步是得有一张“高度图”。这个数据不一定要单独做一张贴图最常见的几种来源贴图的Alpha通道。这是最顺手的方案很多PBR贴图Material的Height通道不舍得用完全可以放进alpha。前提是纹理导入时要把Alpha相关的选项设置正确别被引擎格式压缩丢失了精度。单独的高度贴图Height Map灰度的R16或R8格式。精度比塞在RGBA里更好控制适合要求高的项目。从现有通道“白嫖”。没有专门高度图时可以用AO环境光遮蔽或粗糙度来近似。因为AO天然在高差处更暗粗糙度在裂隙处更高和“高度”有一定相关性。甚至直接把albedo转灰度也能勉强用但草地和沙地的亮度差太小效果会大打折扣。我的经验是能不“白嫖”就别白嫖专门高度图的收益远大于成本。实在要凑合也要选对比度大的通道比如雪地用AO岩石用粗糙度都比纯albedo亮度靠谱。2.2 混合公式逐项拆解为什么是“除以”对比度高度混合的核心公式并不复杂标准版长这样float HeightBlend(float4 texA, float4 texB, float weight, float contrast) { float hA texA.a; // A层的高度 float hB texB.a; // B层的高度 // 核心高度差 原始权重偏置 float bias (hB - hA) / contrast (weight * 2.0 - 1.0); float blend saturate(bias); return lerp(texA.rgb, texB.rgb, blend); }逐个参数聊hB - hAB比A高多少。正数表示B更高混合结果应该偏向B负数表示A更高偏向A。这是“谁高谁覆盖”的直觉来源。除以contrast这里为什么要用除法而不是乘法因为contrast的物理意义是“高度差的阈值”。值越小高度差被放大得越厉害一点点高度差就能让混合结果一分为二过渡锐利值越大高度差被缩小两边要拉开很大差距才能分出胜负过渡柔和。用除法写contrast直接对应“多少高度差算明显”调起来很直觉。weight * 2.0 - 1.0把原始权重从[0,1]映射到[-1,1]。weight0.5时偏置为0完全由高度差决定weight0时强制偏向Aweight1时强制偏向B。这个设计保证了一个重要特性在没有高度差hA等于hB时公式退化成普通的lerp不会破坏原本的权重设置。saturate把结果限幅到[0,1]防止高度差过大时出现负值或超过1的情况避免颜色翻转或后处理报错。调contrast的时候有个技巧先把原始权重固定成0.5把contrast拉得极小比如0.01观察过渡边界是不是锐利得像刀切一样。是的话说明公式逻辑没问题再逐步放大到0.2~0.4感受平滑度。2.3 多层混合顺序、权重归一化与“后涂覆盖”真实地形不可能只有两层草、岩石、泥土、雪加起来四层起步。多层混合没那么多花活最实用的方式是两两迭代先混A和B得到中间结果再用这个中间结果去和C混以此类推。float4 combined texA; combined lerp(combined, texB, HeightBlend(combined, texB, weightB, contrast)); combined lerp(combined, texC, HeightBlend(combined, texC, weightC, contrast));但是这里有两个必须注意的点第一权重归一化。如果各层的weight分别来自一张mask的不同通道它们的和往往是1.2甚至1.5直接拿去用会导致最终颜色整体偏亮或者偏淡。混合前先把所有层的weight加起来做归一化保证总和为1这是最容易被新手忽略的一步。第二混合顺序不是无所谓的。高度混合里“后混入的层”带了高度差偏置会覆盖掉前面已经混合好的结果。我实际测试下来一个比较稳妥的策略是先混合面积大的底层草地、砂石再把具有“覆盖感”的材质雪、苔藓、积水放在最后混。这样雪才能完整地压在石头和草地上而不是被草地反吃掉。3. 实操Unity Shader 实现完整高度权重混合3.1 准备工作贴图导入设置与参数规划开始写代码前先把贴图的导入设置搞定不然后面所有问题都会归结到“怎么调都不对”。在Unity里如果高度数据放在alpha通道贴图的导入设置建议这样sRGB颜色贴图勾选sRGB高度数据所在的贴图建议取消sRGB如果只有高度图通道可以直接把它们当线性数据用。Alpha Source选Input Texture Alpha。Alpha Is Transparency不要勾因为我们不需要透明混合。格式如果追求精度用RGBA Compressed或RGBA32移动端要压缩的话注意ASTC在alpha通道的精度比DXT5更平滑。参数规划上我习惯用一个全局变量控制整体锐度再用每个材质一个参数控制各自的覆盖倾向_Contrast (Transition Sharpness, Range(0.01, 1.0)) 0.253.2 Shader 核心代码可直接抄的版本下面这个版本是我在URP下常驻的写法内置管线也通用只需把HLSLPROGRAM换成CGPROGRAM。代码包含了三层混合实际项目加层数就继续在后面追加。Shader Custom/HeightBlendTerrain { Properties { _ColorA (Layer A Color, 2D) white {} _ColorB (Layer B Color, 2D) white {} _ColorC (Layer C Color, 2D) white {} _WeightMap (Layer Weights (R: A, G: B, B: C), 2D) black {} _Contrast (Height Contrast, Range(0.01, 1.0)) 0.25 _LayerBias (Layer Bias, Vector) (0, 0, 0, 0) } SubShader { Tags { RenderTypeOpaque } LOD 200 HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; }; struct Varyings { float2 uv : TEXCOORD0; float4 positionHCS : SV_POSITION; }; TEXTURE2D(_ColorA); SAMPLER(sampler_ColorA); TEXTURE2D(_ColorB); SAMPLER(sampler_ColorB); TEXTURE2D(_ColorC); SAMPLER(sampler_ColorC); TEXTURE2D(_WeightMap); SAMPLER(sampler_WeightMap); float _Contrast; float4 _LayerBias; Varyings vert(Attributes IN) { Varyings OUT; OUT.positionHCS TransformObjectToHClip(IN.positionOS.xyz); OUT.uv IN.uv; return OUT; } float4 BlendHeight(float4 texA, float4 texB, float weight, float contrast) { float hA texA.a; float hB texB.a; // 原始权重映射到[-1,1]再叠加上高度差的偏置 float bias (hB - hA) / max(contrast, 0.001) (weight * 2.0 - 1.0); float blend saturate(bias); return lerp(texA, texB, blend); } half4 frag(Varyings IN) : SV_Target { float4 texA SAMPLE_TEXTURE2D(_ColorA, sampler_ColorA, IN.uv); float4 texB SAMPLE_TEXTURE2D(_ColorB, sampler_ColorB, IN.uv); float4 texC SAMPLE_TEXTURE2D(_ColorC, sampler_ColorC, IN.uv); float4 weights SAMPLE_TEXTURE2D(_WeightMap, sampler_WeightMap, IN.uv); float wA weights.r _LayerBias.x; float wB weights.g _LayerBias.y; float wC weights.b _LayerBias.z; // 权重归一化避免总和大于1导致颜色偏亮 float total wA wB wC; wA saturate(wA / max(total, 0.001)); wB saturate(wB / max(total, 0.001)); wC saturate(wC / max(total, 0.001)); // 从底层开始混合后层在高度的作用下覆盖前层 float4 result texA; result BlendHeight(result, texB, wB, _Contrast); result BlendHeight(result, texC, wC, _Contrast); return result; } ENDHLSL } }这个版本对新手比较友好逻辑都摊开了。有一点要注意我在BlendHeight里定义了bias (hB - hA) / contrast (weight * 2.0 - 1.0)意思是当weight0.5时高度差完全主导当weight0时即使B更高只要高度差没有大到突破阈值结果仍偏向A。这样可以在mask已经划定好区域的前提下用高度差去微调边界的锐度而不是完全推翻mask。3.3 调试心得如何快速找到合适的对比度参数_Contrast这个参数我从0.01到1.0都试过最容易出效果的是0.2~0.4之间。太小的值会让过渡像刀切远看像刻意画的边界太大的值又退化成普通lerp图层压不住。调试时我有两个固定操作把_WeightMap临时设成纯灰色所有通道0.5这样等于把原始权重固定成了0.5方便单独观察高度差对混合的影响。确认过渡锐度满意后再换回真正的mask微调。开一个Debug面板把高度差可视化。可以直接在片段函数末尾输出float(texA.a - texB.a) * 10用颜色看哪里高哪里低。这个步骤能帮你快速判断是“高度数据不对”还是“对比度参数不对”省下大量猜的时间。3.4 拓展MagicaVoxel 场景中的高度混合思路聊一个我最近在玩的场景用MagicaVoxel搭体素地形然后想给不同高度的表面做材质区分。MagicaVoxel自身的shader是基于体素数据的走的不是传统UV贴图路线但“高度权重混合”的思想完全可以平移过去把体素的高度比如y坐标在单位化后的数值当作高度数据把预设的材质色板当作纹理层在相邻高度区间之间做基于高度差的平滑过渡。效果上很像地形分层着色的进阶版但不再有硬编码的高度阶梯而是像真的沉积地层一样自然过渡。如果你习惯在MagicaVoxel里导出模型再进Unity处理那这套height blend的思路就更直接了把体素导出的高度贴图当alpha通道和普通地形一样套用上面的Shader就行。体素模型自带的精确高度数据反而让混合结果比手绘mask更干净。4. 常见问题与排查技巧实录4.1 过渡太锐利或太模糊先查公式再查数据这是我遇到最多的两个反馈。先对一下现象过渡像刀切几乎没过渡区_Contrast太小了或者高度数据的对比度太大比如alpha通道里数值不是连续变化而是跳变的。建议先把_Contrast拉到0.3~0.5如果还是锐利再去检查高度图本身是不是本身就缺中间值。过渡模糊看不出压边效果_Contrast太大或者高度数据几乎没差异。此时把_Contrast往0.1方向拉同时确认高度图不是一张“纯度不够”的贴图——比如直接用了albedo的亮度而两张albedo本身亮度接近后者怎么调都白搭。一个容易被忽略的点是线性空间和Gamma空间。高度差计算发生在shader里如果高度图被当作sRGB解码了数值会偏亮压缩了暗部的差异导致混合在暗色材质上不敏感。建议把专门的高度图在导入设置里关掉sRGB。4.2 黑边、闪烁、接缝问题精度和mipmap在搞鬼黑边和闪烁大概率是这两个原因一是alpha通道的精度不够。压缩到DXT5之后alpha通道只有8位左右高度值稍微接近就会出现明显的量化台阶。混合边界会鬼畜地抖动甚至出现黑色颗粒。解决办法是给这些贴图关闭压缩或者把高度数据单独放到一张R16贴图里精度立刻上来。二是mipmap的选择。高度混合对mipmap的变化非常敏感——远处三角形走样导致高度采样跳变就会出现典型的“闪烁的边界”。我试过把高度图的mipmap关闭结果远处全是噪声。正确解法是保留mipmap但把高度图的mipmap生成方式改成“均值”而不是“最值”并在shader里对高度差做一定程度的钳制float hDelta clamp((hB - hA) / contrast, -1, 1);这样远处即使高度采样抖动混合权重也只会在一个可控范围内变化不会闪成一片。4.3 多层混合后整体偏亮或偏暗先检查权重和前面提过权重归一化这里再强调一次。三层纹理的mask分别是R、G、B通道如果你直接各自取样后当权重用很少会恰好加起来等于1。比如R0.6、G0.6、B0.3总和1.5这就等于同一像素混入了150%的材质信息最后结果要么过亮要么饱和度奇奇怪怪。还有一点在URP或HDRP里做多层混合时建议全程使用线性空间计算最后在输出时由引擎完成gamma转换。如果你在一些老项目里用Gamma空间也可以运行但对比度和饱和度会和线性空间下差不少别拿两个不同项目直接对比参数。4.4 性能优化层数越多采样越贵高度混合最大的性能开销在于“每个层都要采样高度数据”。三层就是6次采样五层就是10次移动端很容易把GPU带宽吃满。我自己常用的优化思路能用2层解决的不要硬上4层。像沙漠、雪原这类场景2层高度混合加一层远距离噪声视觉上就够丰富了。高度数据不要单独采样一张贴图而是和颜色贴图打包在同一张的alpha通道里这样一次采样同时拿到颜色和高度。移动端Shader模型低的情况下避免在循环里动态索引采样器数组最好直接展开写成两两混合的调用。远处的地形可以直接降低采样层数用LOD切换或者距离因子控制让近处全精度、远处只混2层。5. 最后分享一点我的实操体会高度权重混合这东西公式看着简单但真要在项目里调出“自然”的感觉九成时间都花在高度数据的准备上。我最早图省事直接拿albedo亮度当高度结果怎么调对比度都觉得脏。后来老老实实给每张材质配了独立的高度图世界一下就清净了——数据的质量决定了效果的上限shader只是把这个上限落地而已。另外一个很实用的习惯是把调好的材质参数固化下来存成MaterialPreset而不是每次重新手拉滑块。因为高度混合的“手感”很依赖于贴图的分辨率和采样比例换一套贴图就要重调一遍有个预设能省不少时间。希望这篇对你有帮助去把自己的地形过渡调出那种“积雪压住岩石”的质感吧。