本文已参与[新人创作礼]活动,一起开启掘金创作之路
本文禁止转载,如需转载请注明出处。
图形学的数学基础(四十一):噪声-中
噪声是一个函数,返回一个范围在0-1之间的浮点数。输入参数可能是一维的二维的或者三维四维。本章将主要介绍一维入参的情况。可以使用一个随机数生成器来生成这些浮点数,但是上一章解释过,每次调用噪声函数会生成完全不可预测的数值,这不是我们想要的,生成的这种随机数值通常被称为白噪音()。这种方法生成的数据不适合作为程序化纹理,因为自然界中的噪声纹理通常都是很自然的。
首先我们要做的是以固定间隔创建一系列的随机点.在二维空间中,将在网格中的顶点处生成这些随机值,在1维中,这个网格可以被视为一个标尺,为了简单起见,假设网格的顶点或标尺的刻度沿着x和y轴创建。
创建需要将随机数分配给网格的每个顶点处。对于,随机数将会被分配到x轴固定的间隔处。无论2D还是1D,顶点坐标均为整数,在本示例中,我们仅对前10个数执行此操作,从0到9。
先拿举例说明,可以看到在标尺上的整数位置处定义了一系列的点。例如:当x = 0结果为0.36, x=1结果为0.68,但是当x不是整数时,函数的结果是什么呢? 要计算x轴上任何点的值,需要做的就是找出x轴上该输入值临近的连个整数值(最小值和最大值)。为了计算这个数字,可以使用一种称为线性插值的简单插值技术():
//求出当前位置临近的最小值和最大值
const currentX = 1.27;
const min = Math.floor(currentX);
const max = Math.ceil(currentX);
//拿到最小值和最大值位置的值
const a = getValueByX(min);
const b = getValueByX(max);
//求出当前时间t
const t = currentX - min;
//线性插值计算当前位置的值。
return a*(1-t) + b*t;
使用线性插值计算范围内任意x值类似于画了一条直线(线性),如果对其它区间的任意x值执行相同的操作,我们会得到如下图所示的曲线。通过这种线性插值技术产生的噪声我们称之为;
我们只在x轴上从x = 0 开始的每个整数位置定义了10个随机值,因此我们只能为范围内的任何x计算一个值。 为什么[0:10]而不是[0:9]? 当x在[9:10]范围内时,我们将使用索引9和索引0处的随机值来计算噪声值。如果这样做,曲线的起点和终点是相同的。 换句话说,当x = 0 和x = 10 时的噪声是相同的。让我们复制曲线并将其移动到现有曲线的左侧或右侧。 现有曲线(曲线 1)在 [0:10] 范围内定义,新副本(曲线 2)在 [10:20] 范围内定义。
我们发现在曲线重复连接处没有不连续的情况。因为在曲线1结尾处的值等于曲线2开始处的值。因此可以根据需要制作任意数量的副本,将噪声函数扩展到无限大。x的值不再限制在之间。
接下来就是代码实现部分,我们已经知道如何计算范围内的噪声,但是当x大于9时,例如x = 9.35,我们希望计算在之间的插值函数。按照常规做法计算x的临近最小最大值,我们得到9和10。我们想要使用0而不是10,这里可以使用取模运算符来实现。使用这种技术,我们可以在沿x轴移动时重复循环遍历噪声函数(类似于复制原始曲线)。实现了噪声函数的周期性和连续性。 代码实现如下:
class ValueNoise1D<T> {
public MAX_VERTICES = 10;
public vertices: T[] = [];
constructor() {
for (let i = 0; i < this.MAX_VERTICES; i++) {
this.vertices[i] = Math.random();
}
}
compute(x: number):T {
const xFloor = Math.floor(x);
const xMin = xFloor % this.MAX_VERTICES;
const t = x - xFloor;
const xMax = (xMin === this.MAX_VERTICES -1)? 0 : xMin + 1;
return this.lerp(xMin, xMax, t);
}
lerp<T>(min:T, max: T, t: number):T {
return min*(1-t) + max*t;
}
}
关于现阶段版本的噪声函数还存在另外一个问题,锯齿状的函数曲线看起来不够平滑。如果观察自然界中的随机图案,例如水面波纹,海洋波浪的轮廓,它们通常没有这种锯齿状轮廓,它们的轮廓通常是自然平滑的。现在让我们改进当前版本的噪声函数,在做插值之前,先通过平滑函数(函数图像表现为“S”形,常用的有),针对值做映射()。重要的是要理解插值函数不会改变,我们所作的改变只是在插值前对t值重新映射。 伪代码如下:
function smoothNoise(a: number, b: number, t: number) {
const remapt = smoothFunc(t);
return lerp(a, b, t);
}
我们取函数在的部分用于t值的remap,对应的输出是,但是现在有两个问题需要解决:
- 因为t的取值范围是,在用函数remap之前,需要先乘以
- 输出为,我们所期望的输出为,需要对输出结果做重新映射:
使用cos函数对t值做重映射后的噪声函数图像
function cosineRemap(a: number, b: number, t: number) {
const tRemapCosine = (1- Math.cos(t * Math.PI)) * 0.5;
return lerp(a, b, tRemapCosine);
}
函数常用于噪声函数的实现,关于函数的实现原理在之前的章节中有过推导,这里不再赘述。唯一需要注意就是将函数转换为代码时,由于需要计算t的2和3的幂,可以通过以下代码稍微优化操作:
function smoothStep(min: number, max: number, t: number) {
const rRemapSmoothStep = t * t * (3 - 2 * t);
return lerp(min, max, rRemapSmoothStep);
}
使用smoothStep函数对t做重映射后的噪声函数图像
完善一维噪声函数
在本小节中,我们将快速展示改变噪声函数结果的不同方法。在原始噪声函数版本中,使用10个随机数生成噪声,之后它将以10为周期循环重复。在实际应用中,如此段的周期往往无法满足我们的需求,最终版本的噪声函数将处理更大的周期(256)。另外代码必须处理x为负值的情况。
缩放
通过对输入值x或者输出结果应用缩放因子,可以很容易的改变函数图像的形状。对输入值x应用缩放因子将改变函数的周期性。将x乘以大于1的值将增加函数的周期(增大噪声频率),简而言之,压缩曲线函数,周期变短。 如果x乘以小于1的值,将会沿着x轴拉伸曲线,延长噪声函数周期(频率)。
对输入值x乘以缩放因子,来提高或降低噪声函数频率
const frequency = 0.5;
const freqNoise = valueNoise1D.compute(x * frequency);
第二种情况将函数结果成因缩放因子,会改变函数图像的振幅()。
const amplitude = 0.5;
const amplitudenoise = valueNoise1D.compute(x) * amplitude;
偏移
向噪声函数的输入值加上某个值可以实现将函数图像向左(加上正数)或者向右(加上负数)移动。这种通过向x添加偏移量来移动噪声函数的技术对于随着时间推移对函数进行动画处理(增加每帧的偏移值)非常有用。
有向噪声
通常噪声函数返回范围内的值,但不一定都是这样,取决于它们是如何实现的。可以简单地通过对噪声函数的返回值从映射到我们希望的范围:
const signedNoise = 2 * valueNoise1D(x) - 1;
最后
噪声函数的实现依赖于创建一个随机值数组,其中每个值都被认为位于标尺上的整数位置。这是一个非常重要的观察结果,当我们稍后过滤噪声函数时将非常有用。在本课的第一章中,我们已经提到,当噪声模式太小时,它会再次变成白噪声,并产生一种称为混叠的视觉效果。当噪声函数的频率变得太高时,可以通过过滤噪声函数来消除这种混叠。 问题是要知道什么时候“太高”。这个问题的答案恰好与标尺上每个预定义随机值之间的距离有关:两个连续的随机数相距 1 个单位。记住噪声函数的这个属性是非常重要的。