
1. 项目概述为什么Unity开发者需要掌握NoiseShader的HLSL实现如果你在Unity里做过地形、云层、火焰、水面或者任何需要自然随机感的材质那你肯定绕不开一个词NoiseShader。它不是一个具体的Shader而是一类用于生成程序化噪声纹理的着色器技术的统称。市面上有很多现成的插件比如大名鼎鼎的Shader Graph拖拖拽拽就能生成不错的噪波效果。但作为一个有追求的Unity开发者尤其是技术美术或者图形程序员仅仅会用节点是远远不够的。当项目遇到性能瓶颈或者需要实现一个插件里没有的、风格独特的噪声效果时深入理解其HLSL实现原理和代码结构就成了你从“使用者”变为“创造者”的关键一步。这次我们不依赖任何可视化工具直接深入到HLSL代码层面把NoiseShader这头“黑箱怪兽”拆解开来。你会发现那些看起来神秘莫测的、能生成山脉、木纹、星云的噪声其核心不过是一些精巧的数学函数和位操作。掌握它意味着你获得了在GPU上“凭空造物”的能力——你可以定制独一无二的视觉风格可以为了移动平台将Shader优化到极致也可以在面试中从容应对那些关于“Perlin噪声和Simplex噪声区别”的灵魂拷问。这篇文章就是为你准备的从原理到代码从结构到优化我们一次讲透。2. NoiseShader的核心原理从“随机”到“可控的自然”在开始写代码之前我们必须先搞清楚噪声Noise到底是什么以及我们为什么需要这么多种类的噪声。简单说我们希望计算机生成的“随机”不是电视雪花屏那种完全无序的“白噪声”而是一种连续、平滑、可控的随机这样才能模拟自然界中大多数物质的纹理。2.1 噪声算法的家族谱系与核心思想所有的程序化噪声其核心思想都可以归结为在一个离散的晶格点上预定义随机值然后通过插值函数计算晶格之间任意位置的平滑值。1. 价值噪声这是最直观的思路。我们在一个二维或三维的整数坐标网格晶格的每个顶点上随机分配一个值比如一个灰度值。对于网格内的任意一点P我们找到包围它的四个二维或八个三维晶格顶点根据P点与这些顶点的距离用插值函数最初是线性插值后来多用平滑插值如smothstep或五次多项式计算出P点的值。它的优点是原理简单但缺点是视觉效果略显生硬有明显的网格感因为其随机性只存在于晶格顶点上。2. 梯度噪声以Perlin噪声为代表这是Ken Perlin在1983年提出的革命性方法并因此获得奥斯卡技术成就奖。它的核心改进在于在晶格顶点上随机的不是一个固定值而是一个梯度向量一个方向。对于空间中的任意点P我们计算它到周围每个晶格顶点的向量然后与该顶点上的随机梯度向量做点积。点积的结果可以理解为P点在该梯度方向上的“影响力”或“贡献值”。最后将这些贡献值用平滑插值函数混合起来。由于梯度向量提供了方向性的变化Perlin噪声生成的纹理更加自然、有机消除了价值噪声的网格感成为了图形学中最经典的噪声算法。3. Simplex噪声这是Perlin本人为了改进其经典算法而提出的升级版。Perlin噪声在N维空间需要计算2^N个晶格点的影响计算量随维度指数增长。Simplex噪声的核心是将空间分割成单形二维是三角形三维是四面体而不是超立方体。这样一来无论多少维任意点都只需要计算N1个顶点的影响。Simplex噪声不仅计算效率更高尤其是在四维及以上空间而且其噪声结果具有更好的视觉各向同性各个方向看起来更均匀没有明显的方向性瑕疵。不过其实现涉及到一个将坐标空间“扭曲”到单形空间的数学变换代码稍复杂。4. 分形噪声这通常不是一种基础算法而是一种使用策略。单一频率的噪声看起来过于平滑。为了模拟自然界中多尺度的细节如山峦有大的山脉轮廓也有小的岩石细节我们将不同频率frequency可理解为“缩放”尺度和不同振幅amplitude可理解为“强度”的多个噪声层叠加起来。高频低振幅的噪声增加细节低频高振幅的噪声决定大体形状。这就是著名的“分形布朗运动”fBm是创造复杂自然纹理的必用技术。2.2 HLSL实现的基石伪随机函数在GPU上我们无法获得真正的随机数必须使用确定的伪随机函数。这个函数需要满足相同的输入永远产生相同的输出。这对于基于噪声的程序化生成至关重要保证了结果的可重复性。同时它需要快速且分布均匀。最常用的方法是哈希函数。我们给一个整数坐标比如晶格点的int2或int3坐标通过一系列位运算映射到一个随机的浮点数或向量上。一个经典且高效的二维哈希函数如下float2 hash22(float2 p) { p float2(dot(p, float2(127.1, 311.7)), dot(p, float2(269.5, 183.3))); return -1.0 2.0 * frac(sin(p) * 43758.5453123); }这个函数做了几件事dot(p, ...)将坐标p与两个精心挑选的“魔法数字”向量做点积相当于进行一次线性变换打乱输入。sin(p) * 43758.5453123对点积结果取正弦。正弦函数是周期性的但乘以一个大系数后我们只取小数部分时周期性被打破呈现出混沌特性。frac(...)取结果的小数部分得到一个[0, 1)范围内的值。-1.0 2.0 * ...将范围映射到[-1, 1)适合作为梯度向量的分量。注意使用sin函数的哈希在部分GPU驱动上可能不是完全确定性的尽管极其罕见。在要求绝对确定性的场合如世界生成可以使用更复杂的、纯位运算的哈希比如基于xxhash或wang hash的变种但sin哈希在绝大多数情况下已经足够好且非常快。3. 核心代码结构拆解搭建一个模块化的Noise Shader库理解了原理我们就可以着手搭建代码了。一个好的Noise Shader库不应该是一个巨型的、难以维护的单一文件而应该是模块化、可复用的。下面我将展示一个典型的HLSL代码结构。3.1 基础工具模块哈希与插值我们首先创建一个名为NoiseCommon.hlsl的文件存放所有噪声算法共用的基础函数。// NoiseCommon.hlsl #ifndef NOISE_COMMON_INCLUDED #define NOISE_COMMON_INCLUDED // 1. 哈希函数族 float hash(float n) { return frac(sin(n) * 1e4); } float hash(float2 p) { return frac(sin(dot(p, float2(127.1, 311.7))) * 43758.5453123); } float3 hash33(float3 p) { p float3(dot(p, float3(127.1, 311.7, 74.7)), dot(p, float3(269.5, 183.3, 246.1)), dot(p, float3(113.5, 271.9, 124.6))); return -1.0 2.0 * frac(sin(p) * 43758.5453123); } // 2. 平滑插值函数 (Quintic Hermite Curve) // 比 smoothstep 精度更高二阶导连续是Perlin推荐的标准插值函数。 float fade(float t) { return t * t * t * (t * (t * 6.0 - 15.0) 10.0); // 6t^5 - 15t^4 10t^3 } // 3. 梯度计算辅助函数 (用于Perlin/Simplex噪声) float grad(int hash, float x, float y, float z) { int h hash 15; // 取低4位得到0-15的梯度索引 float u h 8 ? x : y; // 根据索引决定使用哪个方向的梯度分量 float v h 4 ? y : (h 12 || h 14 ? x : z); return ((h 1) 0 ? u : -u) ((h 2) 0 ? v : -v); // 根据hash的位决定正负 } #endif代码解析与心得模块化与头文件保护使用#ifndef和#define防止重复包含这是编写可复用HLSL代码库的好习惯。哈希函数的重载我们为不同维度的输入提供了不同版本的hash函数方便调用。三维的hash33直接返回一个float3这正好可以作为梯度向量。fade函数的重要性这是噪声平滑与否的关键。使用t*t*t*(t*(t*6-15)10)这个五次多项式可以保证噪声函数的一阶和二阶导数都是连续的从而得到非常平滑的过渡。如果使用线性插值你会看到明显的折痕。grad函数的位运算技巧这是经典Perlin噪声的实现技巧。通过位与()操作用预定义的12个梯度向量实际通过hash索引到16个有4个重复来快速计算点积避免了使用随机向量和查表效率极高。3.2 经典算法实现模块Perlin与Simplex噪声接下来我们创建ClassicNoise.hlsl实现最常用的两种噪声。// ClassicNoise.hlsl #ifndef CLASSIC_NOISE_INCLUDED #define CLASSIC_NOISE_INCLUDED #include NoiseCommon.hlsl // 三维Perlin噪声 float perlinNoise(float3 p) { float3 pi floor(p); // 整数部分即晶格左下角原点 float3 pf p - pi; // 小数部分即点在当前晶格内的位置 float3 w fade(pf); // 对三个维度的小数部分进行平滑处理 // 计算八个晶格顶点的贡献 // 这里使用了经典的“散列-梯度”方法需要先对晶格坐标进行哈希得到伪随机的梯度索引 int3 gi000 int3(pi) % 256; // 假设使用一个256的周期避免哈希值过大 int3 gi001 gi000 int3(0,0,1); // ... 省略 gi010, gi011, gi100, gi101, gi110, gi111 的定义 // 实际代码中需要完整计算出8个顶点的哈希索引 // 为每个顶点计算梯度贡献 (使用简化版实际需根据gi000等索引调用hash函数得到梯度向量) // 假设我们有一个根据晶格坐标生成梯度向量的函数 gradVec(pi) float3 g000 gradVec(pi); float3 g001 gradVec(pi float3(0,0,1)); // ... 获取其他顶点的梯度向量 // 计算点积向量(pi-p) 与 梯度向量 的点积 float n000 dot(g000, pf); float n001 dot(g001, pf - float3(0,0,1)); // ... 计算其他7个点积 // 三线性插值使用平滑后的权重w float nx00 lerp(n000, n100, w.x); float nx01 lerp(n001, n101, w.x); float ny0 lerp(nx00, nx01, w.y); // ... 继续在y和z方向上进行插值最终得到结果nxyz float nxyz lerp(lerp(lerp(n000, n100, w.x), lerp(n001, n101, w.x), w.y), lerp(lerp(n010, n110, w.x), lerp(n011, n111, w.x), w.y), w.z); // 将结果从[-1, 1]左右映射到[0, 1]区间更符合纹理采样习惯 return nxyz * 0.5 0.5; } // 二维Simplex噪声 (更高效视觉质量更好) float simplexNoise(float2 p) { // Simplex噪声的核心将空间扭曲到单形空间 const float K1 0.366025404; // (sqrt(3)-1)/2 const float K2 0.211324865; // (3-sqrt(3))/6 // 1. 将输入坐标进行偏斜Skewing将正方形网格变换为单形网格 float2 s floor(p (p.x p.y) * K1); float2 x0 p - s (s.x s.y) * K2; // 2. 判断当前点在哪个三角形内单形并确定另外两个顶点 float2 i1 (x0.x x0.y) ? float2(1.0, 0.0) : float2(0.0, 1.0); float2 x1 x0 - i1 K2; float2 x2 x0 - 1.0 2.0 * K2; // 3. 对三个顶点进行哈希得到梯度向量 // 4. 计算每个顶点的贡献值衰减函数通常用r^2 * (r^2-1)^4这里简化 // 5. 将三个贡献值相加并乘以一个缩放系数如70.0以归一化到[-1,1]附近 // ... (具体实现较长此处为逻辑示意) float n0, n1, n2; // 计算n0, n1, n2... return (n0 n1 n2) * 70.0 * 0.5 0.5; // 映射到[0,1] } #endif实操要点与避坑指南Perlin噪声的“网格对齐”问题经典Perlin噪声的梯度向量来自一个有限的、离散的集合如12个或16个。这可能导致生成的结构有轻微的轴对齐倾向。改进版的“改进型Perlin噪声”会使用hash函数生成真正随机的单位球面上的梯度向量视觉上更各向同性。在实现时你可以根据需求选择经典版快或改进版好。Simplex噪声的专利问题需要注意的是Simplex噪声的3D及以上维度的实现曾受专利保护专利已于2022年1月过期。在此之前许多开源项目使用不同的“单形噪声”变体来规避。现在你可以自由使用但了解这段历史有助于你阅读一些老代码。归一化输出不同的噪声算法原始输出范围不同。Perlin噪声通常在[-1, 1]附近Simplex噪声需要乘以一个经验系数如70.0来归一化。在最终输出前统一映射到[0,1]区间方便后续作为灰度图或混合权重使用。性能考量在Shader中sin、dot、lerp都是相对较快的指令但大量的floor、frac和条件判断可能成为瓶颈。在移动端如果性能吃紧可以考虑使用价值噪声或更简单的哈希噪声作为替代。3.3 应用层模块分形噪声与域扭曲单一噪声太单调我们需要组合它们来创造丰富效果。创建FractalNoise.hlsl。// FractalNoise.hlsl #ifndef FRACTAL_NOISE_INCLUDED #define FRACTAL_NOISE_INCLUDED #include ClassicNoise.hlsl // 基础分形布朗运动 (fBm) float fBm(float3 p, int octaves, float persistence, float lacunarity, float frequency) { float value 0.0; float amplitude 1.0; float maxAmplitude 0.0; // 用于归一化的最大振幅和 float freq frequency; for (int i 0; i octaves; i) { value perlinNoise(p * freq) * amplitude; // 这里可以替换为任何基础噪声函数 maxAmplitude amplitude; amplitude * persistence; // 振幅递减 freq * lacunarity; // 频率递增 } // 归一化使结果大致保持在[0,1]范围 return value / maxAmplitude; } // 域扭曲 (Domain Warping)用噪声本身来扭曲采样坐标产生更复杂、有机的形态 float domainWarpFbm(float3 p, int octaves) { // 第一层扭曲 float3 q float3( fBm(p, octaves, 0.5, 2.0, 1.0), fBm(p float3(5.2, 1.3, 2.8), octaves, 0.5, 2.0, 1.0), fBm(p float3(3.7, 9.2, 1.4), octaves, 0.5, 2.0, 1.0) ); // 在扭曲后的坐标上采样噪声 return fBm(p 0.5 * q, octaves, 0.5, 2.0, 1.0); } // 湍流噪声 (Turbulence)取噪声的绝对值产生尖锐的“湍流”效果常用于火焰、大理石 float turbulence(float3 p, int octaves) { float value 0.0; float freq 1.0; for (int i 0; i octaves; i) { value abs(perlinNoise(p * freq) * 2.0 - 1.0); // 将[0,1]映射到[-1,1]后取绝对值 freq * 2.0; } return value / octaves; } #endif参数详解与调优经验octaves倍频/迭代次数叠加的层数。层数越多细节越丰富但计算成本线性增加。通常4-8层在视觉和性能上取得较好平衡。persistence持久度/振幅衰减每增加一层振幅乘以这个系数。通常设为0.5意味着每一层细节的强度是上一层的一半。这个值越小高频细节越不明显整体越平滑。lacunarity缺项/频率倍增每增加一层频率乘以这个系数。通常设为2.0意味着每一层噪声的“缩放”是上一层的一半频率翻倍。这个值决定了细节的尺度变化。frequency基础频率控制噪声的整体“缩放”。值越大噪声纹理越“密”特征越小值越小纹理越“疏”特征越大。域扭曲的妙用domainWarpFbm函数是创造复杂自然形态如云朵、山脉、熔岩的神器。其思想是不用噪声值直接输出而是用噪声值去偏移采样坐标本身然后再采样一次噪声。这相当于把噪声纹理“揉皱”了再观察能产生极其丰富的细节。偏移量0.5 * q中的系数0.5控制扭曲强度可以调节。4. 在Unity Shader中集成与实战应用有了模块化的HLSL代码库我们就可以在Unity的Shader中轻松调用它们了。4.1 创建自定义着色器与包含头文件在Unity项目中创建一个新的Unlit Shader命名为CustomNoiseShader。将我们写好的NoiseCommon.hlsl、ClassicNoise.hlsl、FractalNoise.hlsl文件放在一个专门的文件夹如Shaders/Include中。在CustomNoiseShader的CGPROGRAM或HLSLPROGRAM块中使用#include指令引入它们。Shader Custom/CustomNoiseShader { Properties { _MainTex (Texture, 2D) white {} _NoiseScale (Noise Scale, Float) 5.0 _Octaves (Octaves, Int) 4 _Persistence (Persistence, Range(0,1)) 0.5 _Lacunarity (Lacunarity, Float) 2.0 } SubShader { Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc // 引入我们的噪声库 #include Shaders/Include/NoiseCommon.hlsl #include Shaders/Include/ClassicNoise.hlsl #include Shaders/Include/FractalNoise.hlsl struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; v2f vert (appdata v) { ... } // 标准顶点着色器 float _NoiseScale; int _Octaves; float _Persistence; float _Lacunarity; fixed4 frag (v2f i) : SV_Target { // 将UV坐标扩展到世界空间或作为噪声输入 float3 samplePos float3(i.uv * _NoiseScale, _Time.y); // 加入时间产生动画 // 调用分形噪声 float noiseValue fBm(samplePos, _Octaves, _Persistence, _Lacunarity, 1.0); // 或者调用域扭曲噪声 // float noiseValue domainWarpFbm(samplePos, _Octaves); // 将噪声值作为颜色输出 return fixed4(noiseValue.xxx, 1.0); } ENDCG } } }4.2 典型应用场景与Shader Graph节点实现思路即使你主要使用Shader Graph理解HLSL原理也能让你更强大。你可以创建Custom Function节点来封装这些HLSL函数。地形高度图使用fBm生成基础地形再用domainWarpFbm增加侵蚀、褶皱的细节。将输出连接到Position节点的Y轴偏移。云层效果使用simplexNoise因其各向同性更好生成fBm输出连接到Transparency通道。通过Smoothstep节点控制云的形状和边缘软硬。水面波纹使用sin函数结合fBm噪声对法线贴图进行扰动。让噪声随时间变化在采样坐标中加入_Time.y即可产生动态波光粼粼的效果。风格化火焰使用turbulence噪声作为基础用Color Ramp节点将灰度噪声映射为从红到黄到白的火焰渐变色。金属表面划痕/污渍使用多种不同尺度的噪声进行混合Multiply,Add,Screen等混合模式模拟复杂的表面磨损。在Shader Graph中创建Custom Function节点的步骤右键点击Shader Graph画布 -Create Node- 搜索Custom Function。在节点属性中将Type设置为String然后将我们写好的HLSL函数体例如整个fBm函数粘贴到Body中。在Inputs和Outputs中定义好输入参数p,octaves等和输出float。现在你就可以像使用内置节点一样拖拽并使用你自己的高性能噪声生成器了。5. 性能优化与常见问题排查将复杂的噪声计算放在Shader中尤其是片段着色器里对性能是严峻的考验。以下是一些关键的优化和调试策略。5.1 性能优化技巧降低精度在移动端或VR项目将float改为half甚至fixed在URP/HDRP中对应half和float。对于噪声函数half精度通常足够且能显著提升性能。减少迭代次数fBm的octaves是性能杀手。在远处或小屏幕上使用更少的倍频。可以考虑根据像素到相机的距离动态调整octavesLOD技术。预计算与烘焙对于静态或变化缓慢的噪声如地形基础高度可以在编辑器模式下预计算并烘焙到一张纹理中。在运行时采样纹理比实时计算噪声要快得多。使用更快的噪声在需要大量计算的地方如体积雾、全屏后处理考虑用价值噪声代替Perlin/Simplex噪声。价值噪声计算量更小虽然质量稍逊但在运动模糊或动态效果中差异不明显。利用顶点着色器如果噪声用于影响顶点位置如简单的水面波动可以在顶点着色器中计算而非片段着色器。顶点数量远少于像素数量。避免分支Shader中的if语句在某些GPU架构上性能很差。尽量用step()、lerp()等数学函数来替代条件判断。例如Simplex噪声中判断点在哪个三角形内可以用float2 i1 (x0.x x0.y) ? float2(1.0, 0.0) : float2(0.0, 1.0);这个三元运算符在HLSL中通常会被编译成效率更高的指令混合而非真正的分支。5.2 常见问题与解决方案速查表问题现象可能原因解决方案噪声有明显的方形或菱形网格图案1. 使用了价值噪声且插值函数不够平滑。2. Perlin噪声的梯度向量集合有限导致方向性。1. 确保使用fade函数五次平滑插值。2. 改用Simplex噪声或使用改进型Perlin噪声用哈希生成随机单位向量。噪声在动画时随时间变化闪烁或抖动1. 哈希函数在时间维度上不连续。2. 对_Time.y直接用作坐标变化太快。1. 确保哈希函数输入是floor后的整数坐标。动画应对小数部分pf施加偏移而非对整数部分pi。2. 将_Time.y乘以一个较小的系数如0.1来控制变化速度。分形噪声在高层数高octaves时出现“灰块”振幅衰减persistence设置不当导致高频层贡献过大累加值超出预期范围。1. 检查并确保fBm函数进行了正确的归一化除以maxAmplitude。2. 降低persistence值如从0.5降到0.4。Shader编译错误重复定义多个.hlsl文件通过#include相互包含导致函数被重复定义。在所有头文件的开尾使用#ifndef/#define/#endif宏进行保护如前文示例所示。在Shader Graph中Custom Function无效1. HLSL语法错误。2. 输入输出参数定义不匹配。3. 函数体内使用了未定义的函数。1. 先在普通的.shader文件中测试HLSL代码确保无误。2. 仔细检查Custom Function节点的输入输出名称、类型与函数声明是否一致。3. 确保函数体内用到的所有辅助函数都已包含在Body或通过其他方式引入。移动设备上帧率骤降片段着色器中的噪声计算过于复杂超出了GPU算力。1. 实施上述所有优化技巧特别是降低精度和倍频数。2. 考虑将效果简化或改为在低分辨率RenderTexture上计算然后上采样。5.3 调试与可视化技巧调试Shader尤其是这种纯数学生成的Shader可视化中间步骤是关键。分步输出在frag函数中你可以临时将中间变量比如单个octave的噪声值、哈希值、梯度向量等直接作为颜色输出。这能帮你精准定位是哪个环节出现了视觉异常。使用Frame DebuggerUnity的Frame Debugger可以查看每一Draw Call的渲染结果确认你的Shader是否正确执行。制作调试材质创建一个专门的调试Shader用不同的颜色通道R, G, B分别显示噪声的不同属性如X方向导数、Y方向导数、曲率等这有助于理解噪声场的结构。掌握NoiseShader的HLSL实现就像是掌握了图形世界的“元语言”。它让你从被动的素材使用者变为主动的规则制定者。起初可能会被那些数学公式和位运算吓到但一旦你亲手实现一遍并看到屏幕上由你编写的几行代码生成了连绵的山脉或翻滚的云海那种成就感是无与伦比的。这份对原理的透彻理解也将成为你解决未来更复杂的渲染难题时最坚实的底气。