写地形材质混合的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 - hA:B比A高多少。正数表示B更高,混合结果应该偏向B;负数表示A更高,偏向A。这是“谁高谁覆盖”的直觉来源。- 除以
contrast:这里为什么要用除法而不是乘法?因为contrast的物理意义是“高度差的阈值”。值越小,高度差被放大得越厉害,一点点高度差就能让混合结果一分为二,过渡锐利;值越大,高度差被缩小,两边要拉开很大差距才能分出胜负,过渡柔和。用除法写,contrast直接对应“多少高度差算明显”,调起来很直觉。 weight * 2.0 - 1.0:把原始权重从[0,1]映射到[-1,1]。weight=0.5时偏置为0,完全由高度差决定;weight=0时强制偏向A,weight=1时强制偏向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 { "RenderType"="Opaque" } 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),意思是当weight=0.5时,高度差完全主导;当weight=0时,即使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。比如R=0.6、G=0.6、B=0.3,总和1.5,这就等于同一像素混入了150%的材质信息,最后结果要么过亮,要么饱和度奇奇怪怪。
还有一点,在URP或HDRP里做多层混合时,建议全程使用线性空间计算,最后在输出时由引擎完成gamma转换。如果你在一些老项目里用Gamma空间,也可以运行,但对比度和饱和度会和线性空间下差不少,别拿两个不同项目直接对比参数。
4.4 性能优化:层数越多,采样越贵
高度混合最大的性能开销在于“每个层都要采样高度数据”。三层就是6次采样,五层就是10次,移动端很容易把GPU带宽吃满。我自己常用的优化思路:
- 能用2层解决的,不要硬上4层。像沙漠、雪原这类场景,2层高度混合加一层远距离噪声,视觉上就够丰富了。
- 高度数据不要单独采样一张贴图,而是和颜色贴图打包在同一张的alpha通道里,这样一次采样同时拿到颜色和高度。
- 移动端Shader模型低的情况下,避免在循环里动态索引采样器数组,最好直接展开写成两两混合的调用。
- 远处的地形可以直接降低采样层数,用LOD切换或者距离因子控制,让近处全精度、远处只混2层。
5. 最后分享一点我的实操体会
高度权重混合这东西,公式看着简单,但真要在项目里调出“自然”的感觉,九成时间都花在高度数据的准备上。我最早图省事,直接拿albedo亮度当高度,结果怎么调对比度都觉得脏。后来老老实实给每张材质配了独立的高度图,世界一下就清净了——数据的质量决定了效果的上限,shader只是把这个上限落地而已。
另外一个很实用的习惯是,把调好的材质参数固化下来,存成MaterialPreset,而不是每次重新手拉滑块。因为高度混合的“手感”很依赖于贴图的分辨率和采样比例,换一套贴图就要重调一遍,有个预设能省不少时间。希望这篇对你有帮助,去把自己的地形过渡调出那种“积雪压住岩石”的质感吧。