URP中沃罗诺伊噪声实现:从原理到移动端优化实战
1. 项目概述为什么要在URP里折腾沃罗诺伊噪声如果你在Unity里做过地形、程序化生成或者风格化渲染大概率听说过Perlin噪声或者Simplex噪声它们能生成连绵的山脉、起伏的云层。但当你需要一些更具“结构性”或“细胞感”的图案时——比如破碎的地面、龙鳞、细胞壁、抽象艺术背景甚至是某些游戏里的能量护盾特效——传统的梯度噪声就显得有点力不从心了。这时候沃罗诺伊噪声就该登场了。沃罗诺伊图也叫泰森多边形其核心思想很简单在空间里随机撒一堆“特征点”然后把整个空间划分成一个个区域每个区域内的任意一点到本区域特征点的距离都比到其他任何特征点都近。这个划分本身就能产生漂亮的细胞状结构。而“沃罗诺伊噪声”就是基于这个距离信息比如到最近特征点的距离或者到第二近特征点的距离差衍生出的一系列可控的、随机的图案函数。在Unity的通用渲染管线URP里实现它意义远不止“多会一种噪声”。URP作为轻量级、可编程的渲染管线是移动端和跨平台项目的首选。在这里手写一个高质量的Voronoi Noise Shader意味着你能获得完全可控性摆脱对Asset Store里黑盒插件的依赖噪声的尺度、形状、混合方式你说了算。性能优化自主权你可以针对URP的Shader框架和你的目标平台比如手机进行极致优化是使用简单的step函数还是更平滑的smoothstep计算一阶还是二阶特征点都可以按需裁剪。深度集成能力它不再是一个外部贴图而是可以无缝融入你的URP Lit或Unlit Shader中作为材质属性动态调整或者驱动顶点偏移、表面纹理混合甚至参与光照计算比如用细胞结构模拟皮革或鳄鱼皮的高光。简单说掌握URP下的Voronoi Noise是你从“Shader使用者”迈向“Shader创作者”的关键一步能让你在表现风格化和技术优化上同时获得主动权。接下来我会从原理到实现再到实战优化带你完整走一遍这个流程。2. 核心原理拆解从“撒点游戏”到可控噪声在动手写代码之前我们必须把沃罗诺伊噪声的数学和逻辑“吃透”。很多教程直接给代码但如果不明白背后的“为什么”一旦需要修改或调试你就会寸步难行。2.1 沃罗诺伊图的基本生成逻辑想象一张无限大的画布。生成沃罗诺伊图的第一步是生成一组随机但稳定的“特征点”坐标。真正的随机每次采样都不同在图形学里是没用的我们需要“伪随机”给定相同的输入比如网格坐标永远输出相同的随机值。通常我们会用一个网格来组织这些点。假设我们将空间划分为一个个单位大小的方格。对于每个方格我们希望在方格内随机生成一个特征点。一个经典方法是使用网格索引作为随机种子// 伪代码获取方格(i, j)内的随机点 float2 random2(float2 p) { // 一个返回二维向量的哈希函数例如 return frac(sin(float2(dot(p, float2(127.1, 311.7)), dot(p, float2(269.5, 183.3)))) * 43758.5453); } float2 featurePoint float2(i, j) random2(float2(i, j));这里(i, j)是当前方格的整数坐标random2函数返回一个在[0, 1)范围内的二维随机向量。所以featurePoint的坐标就在(i, j)到(i1, j1)这个方格内。关键理解这个“方格”就是我们控制噪声频率或者说细胞密度的基础。如果我们想得到更密集的细胞就让方格变小即增大空间的缩放比例。反之则让方格变大。2.2 距离度量与噪声值计算生成了特征点后对于画布上的任意一点p我们需要计算它到周围所有特征点的距离。理论上需要计算无穷多个但实践上我们只需要计算p点所在方格及其相邻的8个方格共3x3网格内的特征点即可。因为更远的方格的特征点距离肯定比这9个方格中的最近点要远。计算距离时我们通常使用欧几里得距离的平方dot(d, d)这样可以避免耗时的开方运算因为距离的大小关系在平方后保持不变。float minDist 1.0; // 初始化为一个较大的值 for (int x -1; x 1; x) { for (int y -1; y 1; y) { float2 neighbor float2(float(x), float(y)); float2 featurePt floor(uv) neighbor random2(floor(uv) neighbor); float2 d featurePt - uv; float dist dot(d, d); minDist min(minDist, dist); } } // 此时 minDist 存储了到最近特征点距离的平方最基础的沃罗诺伊噪声输出就是minDist。直接输出它你会得到在特征点处为0黑色在细胞边界处值最大白色的灰度图这就是细胞噪声Cell Noise。注意这里有一个非常重要的优化点。上述循环是3x39次迭代。在Shader中尤其是片段着色器里循环是性能敏感操作。我们后面会讨论如何利用URP的Shader特性以及可能的优化策略来减少开销。2.3 噪声的变体从F1到F2-F1单纯输出到最近点的距离F1只是开始。通过组合不同顺序的特征点距离我们可以得到丰富的变化F1噪声即上述的minDist是最基础的细胞噪声。F2噪声输出到第二近特征点的距离。其图案和F1类似但“细胞中心”会偏移产生另一种分布。F2-F1噪声输出第二近距离减去第一近距离的差值。这是最有用、最漂亮的变体之一。它会在细胞边界产生尖锐的、类似“裂缝”或“晶格”的线条而在细胞内部则相对平坦。非常适合模拟龟裂地面、玻璃裂纹或水晶结构。加权噪声与曼哈顿距离除了欧氏距离使用曼哈顿距离abs(d.x) abs(d.y)或切比雪夫距离max(abs(d.x), abs(d.y))会产生方形或菱形的细胞风格迥异。还可以给距离加上权重比如让特征点本身带有随机权重从而扭曲细胞的形状。理解这些变体你就能像搭积木一样通过混合不同的距离值创造出独一无二的噪声图案。例如(F2 - F1) * 0.5 0.5可以将差值范围重新映射到[0,1]得到清晰的网状线条。3. URP ShaderLab实战从零构建Shader Graph与代码URP提供了两种主要方式来编写着色器Shader Graph可视化和手写HLSL代码。我们将分别探讨并给出完整的、可直接复用的方案。3.1 方案一使用Shader Graph可视化构建对于不熟悉HLSL语法的开发者Shader Graph是快速原型设计的利器。虽然原生节点库没有直接的Voronoi节点但我们可以用基础节点搭建。第一步创建与设置在Project窗口右键 - Create - Shader - Universal Render Pipeline - Shader Graph。命名为“URP_VoronoiNoise”。在Graph设置中确保“Targets”包含“Universal”。在“Active Targets”下勾选你需要的平台如Universal。第二步构建核心算法节点思路是将原理中的步骤拆解成节点。输入UV与缩放创建一个Vector2类型的属性_Scale连接到Tiling And Offset节点的Tiling输入原UV连接到UV输入。这控制细胞密度。生成网格与随机点使用Fraction节点获取UV的小数部分即点在当前方格内的位置。使用Floor节点获取方格的整数坐标i, j。实现哈希函数这是难点。我们需要一个自定义函数节点Custom Function。创建Custom Function Node将float2类型的i作为输入输出float2类型的rand。在“Source”里选择“String”输入以下HLSL代码void VoronoiRandom_float(float2 p, out float2 rand) { // 一个简单的伪随机函数 rand frac(sin(float2(dot(p, float2(127.1, 311.7)), dot(p, float2(269.5, 183.3)))) * 43758.5453); }将这个函数节点命名为“VoronoiRandom”。将Floor后的坐标输入“VoronoiRandom”节点得到该方格的随机偏移rand。那么该方格的特征点坐标就是float2(i, j) rand。循环与距离计算在Shader Graph中实现精确的3x3循环比较麻烦。一个常见且高效的近似是我们只计算当前点所在方格的特征点以及它相对于该特征点的镜像点。这种方法被称为“快速沃罗诺伊”虽然数学上不精确但视觉上非常接近且只需计算少数几个点性能极佳。实现逻辑计算当前点p到本方格特征点fp的向量d。对d的每个分量判断其是否大于0.5。如果是说明当前点更靠近相邻的方格那么我们应该考虑相邻方格的特征点。我们可以通过调整floor(p)和rand的输入来模拟这一点。具体节点搭建较为复杂需要用到Branch、Compare等节点。更推荐将整个循环计算封装到另一个自定义函数节点中直接输入UV和Scale输出F1、F2等距离值。实操心得在Shader Graph中实现完整的、精确的沃罗诺伊噪声并不轻松尤其是循环部分。它的优势在于快速验证和连接后续效果如颜色渐变、高度混合。如果你的项目对噪声精度要求不是极端严格使用“快速沃罗诺伊”的自定义函数是一个在性能与效果间很好的平衡。将核心算法写在HLSL函数里通过Custom Function节点调用既能利用Graph的可视化优势又能保证代码的效率和灵活性。3.2 方案二手写HLSL Unlit Shader推荐对于追求极致控制和性能的项目我强烈推荐手写Shader。这能让你深入每一个计算细节。第一步创建基本的Unlit Shader文件在Project窗口右键 - Create - Shader - Universal Render Pipeline - Unlit Shader。命名为“URPVoronoi”。打开这个.shader文件你会看到一个基于URP模板的结构。第二步编写Properties和HLSLINCLUDE我们首先在Properties块中定义一些可供材质面板调节的参数Properties { _BaseMap(Base Texture, 2D) white {} _BaseColor(Base Color, Color) (1,1,1,1) _Scale(Noise Scale, Float) 5.0 _Speed(Animation Speed, Vector) (0.1, 0.1, 0, 0) [Toggle]_ANIMATE(Animate Noise, Float) 0 _CellColor(Cell Color, Color) (0.2, 0.5, 0.8, 1) _LineColor(Line Color, Color) (1,1,1,1) _LineWidth(Line Width, Range(0, 0.2)) 0.05 }_Scale控制细胞大小_Speed和_ANIMATE用于制作动态噪声_CellColor和_LineColor用于着色。接下来在HLSLINCLUDE块中或在CGINCLUDE中但URP推荐用HLSLPROGRAM我们编写核心的沃罗诺伊函数。这里给出一个计算F1和F2值的函数// 哈希函数将网格点映射到随机二维向量 float2 hash2(float2 p) { p float2(dot(p, float2(127.1, 311.7)), dot(p, float2(269.5, 183.3))); return frac(sin(p) * 43758.5453); } // 沃罗诺伊噪声函数返回F1和F2 void voronoi(float2 uv, float scale, out float f1, out float f2) { uv * scale; float2 iuv floor(uv); float2 fuv frac(uv); float minDist1 1.0; float minDist2 1.0; // 遍历3x3邻域 for (int x -1; x 1; x) { for (int y -1; y 1; y) { float2 neighbor float2(float(x), float(y)); float2 cell iuv neighbor; // 获取该网格的特征点随机偏移点 float2 point hash2(cell) neighbor; // hash2返回[0,1)加上neighbor后特征点位于正确网格 float2 diff point - fuv; float dist dot(diff, diff); // 使用距离平方 // 更新最近和第二近的距离 if (dist minDist1) { minDist2 minDist1; minDist1 dist; } else if (dist minDist2) { minDist2 dist; } } } f1 sqrt(minDist1); // 可选开方得到真实距离视觉对比更柔和 f2 sqrt(minDist2); }关键点解析注意循环中的point hash2(cell) neighbor。这里hash2(cell)得到的是相对于当前遍历网格cell原点的随机偏移。fuv是当前片段在主方格iuv内的局部坐标。为了正确计算距离我们需要将特征点也转换到以主方格原点为参考的坐标系中所以 neighbor这一步至关重要它把特征点从它所在网格的局部坐标平移到了主方格的局部坐标空间。第三步片段着色器实现在片段着色器函数中我们调用上述函数并利用F1、F2值进行着色。half4 frag(Varyings IN) : SV_Target { float2 uv IN.uv * _Scale; // 应用全局缩放 #ifdef _ANIMATE_ON uv _Time.y * _Speed.xy; // 添加时间偏移以产生动画 #endif float f1, f2; voronoi(uv, 1.0, f1, f2); // 注意缩放已在前面应用这里传入1.0 // 计算F2-F1作为线条强度 float lines smoothstep(0.0, _LineWidth, f2 - f1); // 混合颜色 half4 col lerp(_LineColor, _CellColor, lines); // 可以叠加基础纹理 half4 baseTex SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, IN.uv); col * baseTex * _BaseColor; return col; }这里用了smoothstep来让线条边界有一个平滑过渡_LineWidth控制线条的粗细。通过lerp函数根据线条强度在细胞颜色和线条颜色之间混合。第四步完善Shader框架你需要确保Pass中包含了必要的标签RenderPipelineUniversalPipeline并正确定义了Varyings结构体通常包含uv和顶点位置。同时在SubShader中设置合适的Tags如RenderTypeOpaque。注意事项手写Shader时务必注意URP的版本。不同版本的URP对内置变量如_Time和函数如SAMPLE_TEXTURE2D的命名空间可能略有不同。建议以你当前使用的URP版本模板为准进行修改。如果遇到未定义的标识符错误检查是否包含了正确的HLSL头文件如#include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl。4. 性能优化与移动端适配策略在URP中尤其是在面向移动平台时Shader的性能至关重要。沃罗诺伊噪声的朴素实现3x3循环在片段着色器中可能成为性能瓶颈。以下是经过实战检验的优化策略。4.1 算法级优化减少循环与近似计算快速沃罗诺伊Fast Voronoi 如前所述放弃精确的3x3搜索转而采用一种基于镜像的快速算法。其核心代码如下float fastVoronoi(float2 uv, float scale) { uv * scale; float2 iuv floor(uv); float2 fuv frac(uv); float minDist 1.0; for (int y -1; y 1; y) { for (int x -1; x 1; x) { float2 neighbor float2(float(x), float(y)); // 关键变化直接使用iuvneighbor作为哈希输入但计算相对坐标时做调整 float2 cell iuv neighbor; float2 point hash2(cell); // 将特征点坐标转换到以当前片段uv为中心的局部空间 // 这里是一个简化计算视觉上近似 float2 diff neighbor point - fuv; float dist dot(diff, diff); minDist min(minDist, dist); } } return sqrt(minDist); }这种方法循环次数不变但计算更直接。更激进的版本甚至只计算当前方格和根据fuv分量判断出的最可能的一个相邻方格将循环降至2-4次性能提升显著虽有小幅瑕疵但在许多动态效果或远景中完全可以接受。使用step或smoothstep替代分支在循环内部或后续着色中避免使用if语句。GPU不喜欢分支。用step(a, b)当ba时返回1否则0或smoothstep来实现条件逻辑性能更好。4.2 URP管线特调多管齐下精度选择在移动端将float改为half半精度浮点数可以显著提升性能。在大多数颜色和UV计算中half的精度足够。但要注意在复杂的数学运算如三角函数、频繁的乘加中half可能会溢出或精度不足需谨慎测试。可以将噪声计算核心部分用float最终颜色混合用half。纹理采样优化如果你的沃罗诺伊噪声是静态的或者尺度固定一个终极优化方案是预计算烘焙成纹理贴图。在编辑器模式下用脚本生成一张足够大的Voronoi噪声图然后在Shader中直接采样。这几乎是最快的方案特别适合用于背景、静态地表等。代价是内存占用和缺乏动态变化的能力。LOD与视距剔除对于远处物体可以使用更低的噪声频率更大的方格或者直接切换到更简单的噪声算法甚至不用噪声通过Shader LOD或脚本动态控制。变体剔除在Shader中我们使用了#ifdef _ANIMATE_ON来编译动画变体。确保在不需要动画的材质上不要启用这个关键字避免编译不必要的Shader变体减少包体和运行时内存。4.3 实战性能对比表格下表对比了不同实现方式在一款中端移动设备如Adreno 6系列上的近似性能表现以帧时间或填充率压力为考量实现方案计算开销视觉质量适用场景移动端推荐度Shader Graph 完整循环高高编辑器内原型设计、PC/主机★★☆☆☆手写Shader 3x3循环中高高高质量静态场景、角色皮肤细节★★★☆☆手写Shader 快速沃onoi中中高动态特效、中远景物体、UI元素★★★★☆预计算烘焙纹理极低取决于纹理大小静态环境、重复图案背景★★★★★极简版2-4点采样低中可能有瑕疵粒子特效、大量实例化物体★★★★☆踩坑记录我曾在一个移动端开放世界项目中为地表材质使用了手写3x3循环的Voronoi作为微地形细节。在低端机上出现了明显的帧率下降。排查后发现问题不在于循环本身而在于我在片段着色器中同时计算了F1、F2、F3三种距离并且每个距离都开了平方根sqrt。sqrt在移动GPU上相对昂贵。解决方案是首先只计算必需的F1和F2其次在颜色计算前只对最终用于混合的那个值进行一次sqrt或者尝试用pow(dist, 0.5)近似甚至在某些情况下直接使用距离平方值进行smoothstep也能获得可接受的效果。这个教训是移动端优化要落实到每一个操作尤其是那些在循环内被重复调用的数学函数。5. 创意应用扩展不止于噪声贴图掌握了基础实现后沃罗诺伊噪声的潜力才真正开始释放。它不仅仅是一张灰度图。5.1 动态与程序化动画让噪声“动”起来能创造出无限可能的效果。流动的细胞在传入voronoi函数的UV坐标上添加基于时间的偏移uv _Time.y * _Speed可以让整个细胞图案像液体一样流动。调整_Speed的x和y分量可以控制流动方向。脉动的边界不是移动UV而是让_LineWidth或F2-F1的阈值随时间正弦变化_LineWidth 0.02 0.01 * sin(_Time.y * 3.0)可以让细胞边界呈现呼吸般的脉动效果非常适合用于魔法护盾或生命体表面。特征点动画更高级的做法是让每个特征点自身运动。这需要修改哈希函数使其不仅依赖于空间位置也依赖于时间。例如可以将时间作为第三个维度输入哈希函数为每个特征点生成一个随时间的随机运动向量。这样每个细胞会独立地、有机地蠕动效果非常生动。5.2 驱动顶点位移与表面变形将噪声值从颜色通道转移到顶点位置偏移量上。在顶点着色器中或在片段着色器中通过顶点插值计算当前世界坐标或物体坐标的Voronoi值。使用噪声值通常是F1或F2-F1来沿着法线方向Normal或某个指定方向偏移顶点位置。// 在顶点着色器中 float3 worldPos TransformObjectToWorld(vertexPositionOS.xyz); float2 noiseUV worldPos.xz * _DisplacementScale; // 使用世界XZ平面投影 float f1, f2; voronoi(noiseUV, 1.0, f1, f2); float displacement (f2 - f1) * _DisplacementStrength; // 沿世界Y轴或顶点法线方向偏移 worldPos.y displacement; // 将偏移后的世界坐标转换回裁剪空间 o.positionCS TransformWorldToHClip(worldPos);这可以用于实时生成破碎的冰面、崎岖的岩石表面或扭曲的金属而无需使用高面数模型或复杂的雕刻。5.3 混合与遮罩构建复杂材质沃罗诺伊噪声是绝佳的遮罩生成器。材质混合用F2-F1的线条作为遮罩来混合两种不同的表面纹理。例如模拟生锈金属线条遮罩内使用铁锈材质遮罩外使用干净的金属材质。细节控制用噪声值来控制高光强度Specular Intensity、粗糙度Roughness或法线贴图的强度。例如让细胞边界的粗糙度更高模拟皮肤皱纹或皮革接缝让细胞中心的高光更强模拟湿润或光滑的细胞膜。与其它噪声叠加将Voronoi Noise与Perlin Noise相乘或相加。例如用Perlin Noise作为基础高度再用Voronoi的F2-F1值去“切割”或“侵蚀”它可以生成更加自然、细节丰富的地形。5.4 非视觉应用游戏逻辑与程序化生成它的应用甚至可以超出渲染范畴。地图分区在程序化生成中用沃罗诺伊图来划分地图区域每个细胞成为一个独立的生态区森林、草原、湖泊特征点可以作为该区域的核心点如村庄位置、资源富集点。伤害区域在技能特效中可以用屏幕空间的Voronoi图来定义一种“破碎”式的伤害区域显示每个破碎块代表不同的伤害数值或效果。AI寻路网格虽然不如专业的导航网格精确但在一些抽象或风格化的游戏中沃罗诺伊图生成的点可以作为一种简化的路径点网络。6. 常见问题与调试技巧实录即使理解了原理在实际编码和调试中你依然会遇到各种“坑”。这里记录了我遇到的一些典型问题及解决方法。6.1 问题排查速查表现象可能原因解决方案全屏纯色无噪声图案1. UV输入错误如未正确传递或缩放。2. 哈希函数返回常数值随机性失效。3. 距离计算错误minDist初始值过小或始终未被更新。1. 在片段着色器开头输出UV值如return float4(uv, 0, 1);检查范围是否为[0,1]。2. 检查哈希函数数学确保使用了sin或frac等非线性函数且输入在变化。3. 将minDist初始值设为1e6并逐步输出循环中的dist值。噪声图案呈明显网格状/方块化特征点没有正确偏移到其所属的方格内。在循环中point的坐标没有与neighbor正确关联。确保point的计算是hash2(iuv neighbor) neighbor或等效形式。关键是要将特征点从其局部网格坐标系平移到以当前片段为中心的同一坐标系中。线条断裂或不连续1. 使用了step函数且阈值设置不当。2. 计算F2-F1时F1和F2来自不同尺度的计算精度误差。3. 快速沃罗诺伊算法本身的近似误差。1. 改用smoothstep并给予一个很小的平滑范围如0.005。2. 确保F1和F2在同一个循环中、使用相同的UV和尺度计算得出。3. 换用精确的3x3循环算法。移动设备上帧率骤降1. 片段着色器计算过于复杂循环复杂数学。2. 使用了sqrt、sin等昂贵操作且在循环内。3. Shader变体过多。1. 采用“预计算纹理”或“快速沃罗诺伊”方案。2. 移除循环内的sqrt尝试使用距离平方进行后续计算用查找表或近似函数替代sin。3. 检查材质是否启用了所有可能的关键字剔除不需要的Shader变体。动画时噪声整体“闪烁”或“跳动”对UV添加时间偏移时尺度_Scale设置过大导致特征点跨越网格边界时发生“跳变”。哈希函数在整数输入上的剧烈变化导致。1. 降低_Scale值或提高动画速度的精度使用_Time.y * 0.1。2. 使用基于位置的平滑动画例如对特征点位置进行线性插值而不是让UV整体移动。这需要更复杂的实现但动画质量极高。6.2 调试技巧可视化中间步骤当噪声效果不符合预期时最有效的调试方法是将中间变量可视化。输出特征点位置在片段着色器中暂时返回float4(hash2(floor(uv)), 0, 1)。你应该看到每个方格内有一个随机的颜色点。如果全是同一个颜色说明哈希函数有问题。输出最近距离直接返回float4(f1, f1, f1, 1)。你应该看到一个从黑色特征点到白色边界的平滑渐变图。如果是一片纯白或纯黑检查距离计算和minDist更新逻辑。输出网格索引返回float4(frac(floor(uv.x*10)/10), frac(floor(uv.y*10)/10), 0, 1)。这能帮你看清UV是如何被网格划分的。6.3 关于“特征点跳跃”的深度解决这是一个高级问题当用时间_Time.y去动画化UV时在特定的_Scale下你会看到整个噪声图案突然“跳”了一下。这是因为floor(uv)函数在uv的整数边界处会发生突变导致用于哈希的整数输入突变从而特征点位置突变。解决方案使用平滑的噪声函数或者实现“平滑沃罗诺伊”。一种经典方法是不仅计算到特征点的距离还计算该距离的梯度并使用五次多项式平滑函数如f(x) 6x^5 - 15x^4 10x^3对网格边界处的贡献进行平滑插值。这属于Value Noise或Perlin Noise中常用的技术移植到Voronoi上计算量会增大但能彻底消除跳跃感获得极其流畅的动画。对于大多数实时应用如果跳跃不明显稍微调整_Scale和_Speed参数避开整数倍关系是更实用的办法。最后别忘了多动手尝试。调整_Scale看看细胞如何从巨大变得密集改变哈希函数里的魔法数字127.1, 311.7等你会发现噪声的“风格”会发生微妙变化尝试用曼哈顿距离替代欧氏距离感受从圆形细胞到菱形细胞的转变。这些参数就是你的调色板而沃罗诺伊算法是你的画笔在URP这块画布上能创造出多少令人惊叹的效果完全取决于你的探索。