zhaoyinjun001 发表于 2023-3-30 15:36

游戏中的随机与噪声—原理、Unity 实现与效果展示

随机(Random)

一维伪随机函数

在实际的开发中,我们经常需要用到随机,一般的编程语言中也都会提供Math.Random()之类的伪随机函数。但是,在 GLSL/HLSL 中并没有提供类似的伪随机函数,接下来我们来手动实现一个。
诸如此类的函数有很多,我们可以从如下函数入手:
float y = frac(sin(x));



通过代码可以看出,它提取了sin(x)的小数部分(准确来说是向 1 取模),因为sin(x)的值域为[-1.0, 1.0],所以形成了上述的函数图像。
如果想让这个函数更加“混沌”一些,那么可以在sin(x)外面再乘上一些系数,这样可以让它的频率更大一些。
float random(float x) {
    return frac(sin(x) * 43758.5453123);
}



现在看来,它已经足够“混沌”,足以用来当作自定义的伪随机函数。
二维伪随机函数

同理,我们可以相对应的实现二维的伪随机函数。二维伪随机函数输入两个维度的值,返回一个随机数,那么我们可以将二维的输入变成一维的,然后再将其放入一维的伪随机函数。如果将二维的输入视为向量的话,可以用点积dot()函数来得到一个标量。
float random2D_2To1(float2 x) {
    return frac(sin(
      dot(x, float2(12.9898, 78.233))
    ) * 43758.5453123);
}



若输入的是一幅图片的 UV 坐标,输出的随机值作为像素颜色的 RGB 分量的话,效果就如上图所示。
噪声(Noise)

在真实世界中,我们会发现很多事物看起来都是随机的,如石头、木头的纹理,云朵、波浪的形状。但是,它们并不像是伪随机函数结果那样的完全随机,而是好像有某种规律在其之中。



假如直接使用上面的噪声函数来模拟这些效果(下图左)的话,可以发现是完全不行的(下图中),即使经过模糊(下图右),也依然不相似。



稍微观察后,可以发现一个最明显的规律——在一定的范围内,它们的相邻部位并不是完全的随机关系,而是有某种变化——是从一种状态过渡到另一状态的。那么,如果尝试将原本的随机函数之间加上稍许过渡,是不是就能模拟这种带有过渡的随机呢?这就是下面所讲述的各种噪声(Noise)函数。
简单来说,就是随机,但不完全随机。
一维 Value Noise

可以先从一维的伪随机函数入手。首先,可以通过floor函数来将整个函数分为一段一段的。
float i = floor(x);
float f = frac(x);
float y = random(i);



然后再通过lerp函数将其每一段之间混合一下。
float i = floor(x);
float f = frac(x);
//混合这一段两边顶点上的值
float y1 = random(i);
float y2 = random(i + 1.0);
float y = lerp(y1, y2, f);



但是可以发现,它现在是一条折线,每一段的之间的过渡并不顺滑,这是因为上述的混合实际上是一个y = kx的函数,在过渡后,每一段的顶点的两边的导数并不相等,这一点并不是可导的。想让其更加顺滑的话,需要换用可以让其在两边导数相等的混合函数,比如下面的函数。
//常用的 smoothstep 函数内部就是这么实现的
float y = f * f * (3.0 - 2.0 * f);
它在两个端点的导数均为 0,所以用它来进行混合过渡的话,每个端点两边的导数是相等的,整体看起来就会顺滑许多。
float valueNoise(float x) {
    float i = floor(x);
    float f = frac(x);
    float f2 = f * f * (3.0 - 2.0 * f);
    float y1 = random(i);
    float y2 = random(i + 1.0);
    float y = lerp(y1, y2, f2);
    return y;
}



这个平滑的函数就是一维的 Value Noise。
顺带一提,这个函数在大部分情况下已经可以满足需求了,但实际上还有更平滑的函数。上面的函数在处的导数虽然为 0,但是二阶导并不为 0,下面的函数在处的一阶导和二阶导均为 0。
float y = x * x * x * (x * (x * 6.0 - 15.0) + 10.0);
二维 Value Noise

下面来尝试推导下二维的 Value Noise,首先是分段。已知,一维的 Value Noise 是在 X 轴方向上一段一段的线条,那么二维的 Value Noise 就是在 X-Y 平面上的一个一个格子。
float2 iPos = floor(uv);
float2 fPos = frac(uv);
float value = random2D_2To1(iPos);
half4 color = half4(value.xxx, 1.0);



接着同样通过lerp函数,对格子中的每一点,根据权重混合格子四个顶点上的颜色。



float2 iPos = floor(uv);
float2 fPos = frac(uv);

//混合格子四个顶点上的颜色
float value1 = random2D_2To1(iPos);
float value2 = random2D_2To1(iPos + float2(1.0, 0.0));
float value3 = random2D_2To1(iPos + float2(0.0, 1.0));
float value4 = random2D_2To1(iPos + float2(1.0, 1.0));

float value = lerp(
    lerp(value1, value2, fPos.x),
    lerp(value3, value4, fPos.x),
    fPos.y
);

half4 color = half4(value.xxx, 1.0);
就可以得到如下的结果。



同样的,这幅图片并不平滑,下面再换用平滑的函数将其混合一下。
float valueNoise2D(float2 uv) {
    float2 iPos = floor(uv);
    float2 fPos = frac(uv);
    float2 fMix = smoothstep(0.0, 1.0, fPos);

    float value1 = random2D_2To1(iPos);
    float value2 = random2D_2To1(iPos + float2(1.0, 0.0));
    float value3 = random2D_2To1(iPos + float2(0.0, 1.0));
    float value4 = random2D_2To1(iPos + float2(1.0, 1.0));

    float value = lerp(
      lerp(value1, value2, fMix.x),
      lerp(value3, value4, fMix.x),
      fMix.y
    );

    return value;
}



这个平滑的图像就是二维的 Value Noise。
二维 Gradient Noise

在实际应用中,Value Noise 已经可以满足很多需求了,但是它依然存在一个问题,就是它存在很强的“块状感”。为了消除这种块状的效果,在 1985 年 Ken Perlin 开发了另一种算法——Gradient Noise。
相较于 Value Noise,Gradient Noise 的核心思想在于,它使用渐变/梯度(Gradient) 替换了单元格顶点上的随机值——我们为每个顶点生成的随机值不再是一个标量,而是一个二维的有方向的向量,它每一维都在 -1 ~ 1 内,这样这个向量可以指向平面上的任意方向。之后在插值阶段,会先将顶点上的随机向量投影到(点乘)该顶点到这一点的方向向量得到一个标量值,最后再用这个标量值进行插值得到最终结果。



下方是实际的代码实现与效果,可以看出它几乎没有什么块状感。
//新的随机函数,输入两个值返回两个随机值
float2 random2D_2To2(float2 uv) {
    return frac(sin(float2(
      dot(uv, float2(127.1, 311.7)),
      dot(uv, float2(269.5, 183.3))
    )) * 43758.5453123);
}

//注意返回的是 -1 ~ 1 范围内的值,映射成颜色时要 * 0.5 + 0.5
float gradientNoise2D(float2 uv) {
    float2 iPos = floor(uv);
    float2 fPos = frac(uv);
    float2 fMix = smoothstep(0.0, 1.0, fPos);

    float gradient1 = dot(-1.0 + 2.0 * random2D_2To2(iPos), fPos);
    float gradient2 = dot(-1.0 + 2.0 * random2D_2To2(iPos + float2(1.0, 0.0)), fPos - float2(1.0, 0.0));
    float gradient3 = dot(-1.0 + 2.0 * random2D_2To2(iPos + float2(0.0, 1.0)), fPos - float2(0.0, 1.0));
    float gradient4 = dot(-1.0 + 2.0 * random2D_2To2(iPos + float2(1.0, 1.0)), fPos - float2(1.0, 1.0));

    float gradient = lerp(
      lerp(gradient1, gradient2, fMix.x),
      lerp(gradient3, gradient4, fMix.x),
      fMix.y
    );

    return gradient;
}



我们可以在此基础上实现很多丰富的效果。
如简单的将颜色分 10 阶,然后将阶数按 3 取余,以余数选取红黑白三种颜色,就可以得到如下效果。



Simplex Noise

2001 年的 Siggraph 上,Ken Perlin 又提出了 Simplex Noise。Simplex Noise 不是一种新的噪声类型,而是一种新的计算方式。Simplex 指的是单形,即填充N维空间所需要的最简单的形状,该形状具有N + 1个顶点。二维空间的单形就是三角形,三维空间的单形就是四面体。使用更少的顶点可以降低计算复杂度,也可以进一步减少人工痕迹。
例如,在上文中计算噪声使用的都是四个点组成的四边形晶格,Simplex Noise 则使用了三角形来划分平面。如下图就是以等边三角形来划分平面的示例。



划分完毕之后,下面就是实现自定义的float simplexNoise(float2 uv)方法了,这个方法接受一个二维点,返回该点的噪声值。但这里有一个问题,我们之前传入的点处在以正方形来划分的平面,将坐标点进行floor就可以得到正方形四个顶点的值,但现在传入的点处在以等边三角形来划分的平面,这时候该如何得到这个点所在的三角形的三个顶点的值呢?
如下图,有个很巧妙的方法。其实可以将等边三角形“歪斜”成正方形网格,这样原本对向的两个等边三角形变成了一个正方形中以对角线划分出来的两个等腰三角形。并且,通过比较 x 和 y 的大小,就可以判断出某点是在上三角形还是下三角形。



所以,计算二维 Simplex Noise 的步骤就是:

[*]将该点从以三角形划分的坐标,转换到以正方形划分的坐标。
[*]在此坐标系下,计算出该点所处的等边三角形的三个顶点的坐标。
[*]将这三个顶点转换回三角形划分的坐标,计算出每个顶点的噪声值。
[*]根据一定的权重,计算出该点的最终噪声值。
下面是具体的公式。
首先是将以三角形划分的坐标转换到以正方形划分的坐标的公式,x^\prime、y^\prime是以正方形划分的坐标,x、y是以三角形划分的坐标:
x^\prime = x + (x + y) \times F\\y^\prime = y + (x + y) \times F\\
其中:
F = \frac{\sqrt{3} - 1}{2}
然后是将以正方形划分的坐标转换到以三角形划分的坐标的公式:
x = x^\prime - (x^\prime + y^\prime) \times G\\y = y^\prime - (x^\prime + y^\prime) \times G\\
其中:
G = \frac{1 - \sqrt{\frac{1}{3}}}{2} = \frac{3 - \sqrt{3}}{6}
在 Simplex Noise 里,采样点的噪声值由三个顶点在其方向上的梯度贡献按一定权重累加起来得出。



权重的大小依赖于一个径向衰减系数公式:
h = \max(0, r^2 - d^2)^4
其中,r^2为0.5,d为三角形顶点到该采样点的向量。
所以某个顶点对采样点的贡献值为:
n = \max(0, r^2 - d^2)^4 \times d \cdot gradient
在最后,将三个顶点的贡献值累加起来,然后归一化到[-1.0, 1.0]:
noise = (n_1 + n_2 + n_3) * 70.0
下面是具体的代码实现。
//传入的点处在以等边三角形来划分的平面
float simplexNoise2D(float2 uv) {
    const float F = 0.366025404; //(sqrt(3) - 1) / 2;
    const float G = 0.211324865; //(3 - sqrt(3)) / 6;

    //三角形下的采样点
    float2 tri_p = uv;
    //正方形下的采样点
    float2 rect_p = tri_p + (tri_p.x + tri_p.y) * F;

    //正方形下的原点
    float2 rect_p0 = floor(rect_p);
    //三角形下的原点
    float2 tri_p0 = rect_p0 - (rect_p0.x + rect_p0.y) * G;
    //三角形下的原点到采样点向量
    float2 tri_p0_p = tri_p - tri_p0;

    //正方形下的另一个三角形顶点1
    float2 rect_p1 = rect_p0 + ((tri_p0_p.x < tri_p0_p.y) ? float2(0.0, 1.0) : float2(1.0, 0.0));
    //三角形下的顶点1
    float2 tri_p1 = rect_p1 - (rect_p1.x + rect_p1.y) * G;
    //三角形下的顶点1到采样点向量
    float2 tri_p1_p = tri_p - tri_p1;

    //正方形下的另一个三角形顶点2
    float2 rect_p2 = rect_p0 + float2(1.0, 1.0);
    //三角形下的顶点2
    float2 tri_p2 = rect_p2 - (rect_p2.x + rect_p2.y) * G;
    //三角形下的顶点2到采样点向量
    float2 tri_p2_p = tri_p - tri_p2;

    //衰减系数
    float3 h = max(0.5 - float3(
      dot(tri_p0_p, tri_p0_p),
      dot(tri_p1_p, tri_p1_p),
      dot(tri_p2_p, tri_p2_p)
    ), 0.0);

    //根据衰减系数混合三个顶点后得到的采样点的值
    float3 n = h * h * h * h * float3(
      dot(tri_p0_p, -1.0 + 2.0 * random2D_2To2(rect_p0)),
      dot(tri_p1_p, -1.0 + 2.0 * random2D_2To2(rect_p1)),
      dot(tri_p2_p, -1.0 + 2.0 * random2D_2To2(rect_p2))
    );

    //将值归一化到 [-1, 1]
    return dot(float3(70.0, 70.0, 70.0), n);
}
其中有几点需要讲解一下。
首先是r^2为什么等于0.5。因为正方形的边长为1,经计算可知,等边三角形的边长为\sqrt{2 / 3},高为\sqrt{1 / 2}。采样点离顶点越远,该顶点对其贡献就越少。算法中使用了顶点到对边的距离作为距离的最大值,即高。那么贡献系数就是高的平方减去采样点到顶点的距离的平方。所以式子中的0.5就是高的平方。
然后是最后归一化为什么要乘上70,这个数字从哪里来。当点落在三角形某一边的中点的时候,整体衰减贡献最大,这时,若三个顶点的梯度方向与顶点到采样点的方向相同,则产生最大值,方向相反,则产生最小值。上面已经知道了三角形的边长和高,可以计算得出这时候顶点到采样点的三个模长可以为(\sqrt{1 / 2}, \sqrt{1 / 6}, \sqrt{1 / 6}),h可以为(0, 1 / 3, 1 / 3)。又因随机梯度的最大值为(1.0, 1.0),模长为\sqrt{2}。所以此时的累计和的最大值为:
\begin{align*}noise &= 0^4 \times \sqrt{2} \times \sqrt{\frac{1}{2}} + \left (\frac{1}{3} \right )^4 \times \sqrt{2} \times \sqrt{\frac{1}{6}} + \left (\frac{1}{3} \right )^4 \times \sqrt{2} \times \sqrt{\frac{1}{6}}\\&= \frac{2 \sqrt{3}}{243}\\&\approx \frac{1}{70}\end{align*}
所以最后要乘上70来归一化到[-1.0, 1.0]。
下面是实际运行的效果。



Worley Noise

除此之外还有其它种类的噪声,下面介绍一下 Steven Worley 所发明的 Worley Noise。这种噪声基于距离场,这里的距离指的是当前片元到某个特征点集合之间最近的距离,想要得到这个最近的距离,最直接的方式就是计算出每一条距离,然后计算最小值。直观上来说,最后可以将这个最小距离作为该点的颜色显示出来。
如下图中有 5 个特征点,最下方的片元若想得到自己到这些特征点的最短距离的话,只需将这几个距离都计算出来,然后使用min函数。



如果我们将整个噪声图划分为一个个等长的小格子,每一个格子中放置一个特征点,那么若想计算某片元到全部特征点集合的最短距离,只需计算该片元到其所在的九宫格内的 9 个点的最短距离即可。



下面是实际的代码实现。
float3 worleyNoise2D(float2 uv) {
    float2 iPos = floor(uv);
    float2 fPos = frac(uv);

    float minDist = 2.0;
    float2 minPoint;
    for(int i = -1; i <= 1; i++) {
      for(int j = -1; j <= 1; j++) {
            float2 neighbourBias = float2(i, j);
            float2 thePoint = random2D_2To2(iPos + neighbourBias);
            //thePoint = 0.5 + 0.5 * sin(thePoint * 100.0 + _Time.y); //让点四处动起来

            float dist = length(neighbourBias + thePoint - fPos);
            if(dist < minDist) {
                minDist = dist;
                minPoint = thePoint;
            }
      }
    }

    return float3(minDist, minPoint);
}
该函数返回一个float3,第一个分量是最近距离,后两个分量是最近距离的点。若将最近距离作为该点的颜色输出,就可以得到如下的效果。
float3 worleyNoise = worleyNoise2D(uv);
half3 finalColor = worleyNoise.xxx;



当然还可以在此函数的返回值上做稍许修改来得到一些更有些意思的效果。
finalColor += 1.0 - step(0.02, worleyNoise.x); //在最黑的部分的中心显示白点



finalColor = floor(worleyNoise.x * 10.01) / 10.01; //等高线效果1



finalColor = step(0.7, abs(sin(20.0 * worleyNoise.x))); //等高线效果2



finalColor -= step(0.7, abs(sin(20.0 * worleyNoise.x))) * 0.5; //等高线效果3



finalColor = dot(worleyNoise.yz, float2(0.3,0.6)); //特殊效果1



finalColor = float3(dot(worleyNoise.yz, float2(0.930,0.020)), dot(worleyNoise.yz, float2(0.550,0.580)), 1.0); //特殊效果2



分形噪声(FBM)

上面描述了各种噪声的效果与实现,接下来从最基本的角度来看待一下噪声。从最初的一维 Value Noise 就可以很容易的看出,噪声实际上就是一种波,其包含波的两个最重要的属性——振幅(amplitude)和频率(frequency)。



拿最简单的sin(x)函数举例,波的振幅和频率可以通过如下方式调节,这就是调幅(AM)和调频(FM)。
float amplitude = 1.0;
float frequency = 1.0;
float y = amplitude * sin(x * frequency);
波的另一个性质就是可以相互叠加,如在实现最简单的 FFT 动态水面的时候,水面上某点的高度就可以通过该点到某几个波源特征点的距离进行叠加。简单来说,将几个波进行叠加可以得到形状更加丰富的波。
从噪声的角度来说,既然噪声是一种波,那么自然可以将多副噪声叠加起来得到新的噪声。通过在循环中叠加噪声,并以一定的倍数增加下一幅噪声的频率、减小下一幅噪声的振幅,最终叠加合成的噪声就会有更好的细节,这种方式就叫做分型布朗运动(Fractal Brownian Motion),简称 FBM,或者称为分型噪声(Fractal Noise)。
一些资料中将一次循环成为一个八度(octave)。
下方的代码就是一个最简的实现,其将 Gradient Noise 叠加了 10 次,并在每一次循环中都将频率翻倍,振幅减半。
half4 frag(Varyings input) : SV_Target {
    float2 uv = input.uv * 10.0;

    half3 tmpColor = half3(0.0, 0.0, 0.0);

    float a = 1.0;
    float f = 1.0;
    for(int i = 0; i < 10; i++) {
      half noiseVal = a * gradientNoise2D(uv * f);
      tmpColor += noiseVal.xxx;
      a *= 0.5;
      f *= 2.0;
    }

    return half4(tmpColor * 0.5 + 0.5, 1.0);
}
于是就可以得到如下的效果。



将其与下图的基本 Gradient Noise 摆在一起,可以看出经过叠加后,图片的大致形状基本没有变化,但细节丰富度增加了许多。



同样的,可以将 Simplex Noise 进行叠加,下面是叠加的效果。
half noiseVal = a * simplexNoise2D(uv * f);



在叠加的时候使用abs()取绝对值,可以得到如下效果。
half noiseVal = a * abs(simplexNoise2D(uv * f));
//……
return half4(tmpColor, 1.0);



反相一下是这样的效果。
half noiseVal = a * abs(simplexNoise2D(uv * f));
//……
return half4(1.0 - tmpColor, 1.0);



密度减小点来看,可以看出它可以用来模仿地形的山脊或是沟壑。



又或是不取绝对值,只是将原本的颜色从离散化为 3 阶,然后涂上一些颜色,看起来就会像是地图。



当然,也可以将不同种类的噪声进行叠加,在此就不再赘述。
参考资料


[*]https://thebookofshaders.com/10/?lan=ch
[*]https://thebookofshaders.com/11/?lan=ch
[*]https://thebookofshaders.com/12/?lan=ch
[*]https://thebookofshaders.com/13/?lan=ch
[*]https://www.scratchapixel.com/lessons/procedural-generation-virtual-worlds/procedural-patterns-noise-part-1/introduction.html
[*]https://www.scratchapixel.com/lessons/procedural-generation-virtual-worlds/perlin-noise-part-2/perlin-noise.html
[*]https://iquilezles.org/articles/morenoise/
[*]https://iquilezles.org/articles/gradientnoise/
[*]https://iquilezles.org/articles/fbm/
[*]https://weber.itn.liu.se/~stegu/simplexnoise/simplexnoise.pdf
[*]https://adrianb.io/2014/08/09/perlinnoise.html
[*]https://stackoverflow.com/questions/12964279/whats-the-origin-of-this-glsl-rand-one-liner
[*]https://blog.csdn.net/candycat1992/article/details/50346469
[*]https://www.jianshu.com/p/9cfb678fbd95
本文使用 Zhihu On VSCode 创作并发布
页: [1]
查看完整版本: 游戏中的随机与噪声—原理、Unity 实现与效果展示