p5.js 创意编程, 柏林噪声和粒子随机游走

最后更新于 2023-05-07

本教程我们主要介绍柏林噪声以及基于柏林噪声和粒子随机游走模型的作品实现。

主要涉及到柏林噪声函数的使用、粒子系统 (Particle System) 的实现

## 作品分析

上面是一个常见的基于柏林噪声和流场的例子,其主要由以下几个部分组成:

  1. 基于柏林噪声生成计算流场
  2. 创建一个简单的粒子模型
  3. 让粒子在流场中随波逐流

下面我们就来一步一步实现它。

## 柏林噪声

柏林噪声(Perlin noise)是由 Ken Perlin 发明的一种自然噪声生成算法,它可以生成比较平滑的随机数。与简单的随机数生成器相比,柏林噪声的生成结果更加自然和真实。它可以生成连续的、平滑的随机数序列,并且相邻的随机数之间的差异较小,呈现出比较自然的变化趋势。因此,柏林噪声被广泛应用于生成各种自然系统的场景,比如山脉、云彩、水流等等。

在计算机图形学和游戏开发领域,柏林噪声被用来模拟各种自然现象,游戏地图、草地、岩石、水面等等。

## 柏林噪声在 p5.js 中的使用

在实现上述的设计之前,我们先看看柏林噪声及其在 p5.js 中的使用。

p5.js 提供了便捷的柏林噪声生成器函数——noise()。通过这个函数,我们可以轻松地在代码中生成噪声。

noise() 函数有三种参数调用方式,它的返回值是一个介于0到1之间的数值:

本教程中,我们重点介绍一维噪声和二维噪声。

### 一维噪声

下面我们来看看一维噪声的使用:

在这段代码中,我们首先根据 x 坐标生成一维噪声,并将噪声值映射到 y 坐标,得到了当前点 A。接着,我们将点 A 与该点横坐标对应的最高点 B 连接,从而形成一座山峰。多次重复这一过程,我们就得到了上述山脉状的图形。

点的计算方式如下图所示:

我们具体解释一下这几行简短的代码

setup 是画板初始化的方法:

draw 是画板渲染的方法:

  1. for (let i = 0; i < width; i++) {}
    这是 JavaScript 里的一个 for 循环语法。0 即是画板最左边的 x 坐标,width 是画板的宽度,即是画板最右边的 x 坐标。我们通过一个 for 循环,从画板左边开始的位置一直遍历到画板右边结束的位置。
  2. const noiseVal = noise(i * scaleVal)
    这是调用noise函数的代码。为了获取当前 x 坐标的一维噪声值,我们将正在遍历的 x 坐标乘以一个名为 scaleVal 的数值。scaleVal 是什么呢,为什么要将 x 坐标乘以这样一个数值呢?在noise函数中,通常情况下,坐标之间的差异越小,噪声序列就越平滑。因此,我们将 x 坐标乘以特定的数值,以减小 x 坐标的变化量。通常情况下,我们可以将这个特定的数值 scaleVal 取值在 0.0050.03 之间,但在具体情况下,我们也可以进行相关的调整。参见 processing.org 的 noise 介绍
  3. line 是 p5.js 内置方法,用来画一条线段。

下面我们将该例子改成动画的效果,可以更方便地看出:

### 二维噪声

二维噪声是基于数值对生成的二维噪声序列,可以通过在代码中使用 noise(x, y) 函数调用,我们通常针对点的位置来生成二维噪声值。

下面我们通过几个具体的例子来认识二维噪声和它的使用。

#### 烟雾的模拟

在烟雾模拟的例子中,我们将点的坐标转化为颜色的灰度值,然后以该灰度颜色来绘制点。

具体实现如下:

  1. 使用一个嵌套 for 循环来遍历画板上的每个像素。
  2. 使用每个像素的坐标来计算该点对应的二维噪声值,我们使用了 noise(i * noiseScale, j * noiseScale), noiseScale 参见上一节的解释,它对坐标数值进行缩小,以获得平滑的噪声值。
  3. 将点坐标对应的噪声值映射为颜色灰度值。
  4. 在该像素点使用灰度值绘制将点绘制出来。

#### 生成不规则自然图形

点和线的乐章教程中,我们学会了基于三角函数和点画出一个圆形。在本例子中,我们将在圆形的基础上更进一步,利用二维噪声来画出不规则的图形。

大部分的代码都是在点和线的乐章中用到过的,包括三角函数的计算,圆上具体点的位置计算等。我们重点看看以下几行和二维噪声相关的代码:

const circlePosition = {
    x: width / 2,
    y: height / 2
}

const noiseScale = 0.008
const points = []

for (let i = 0; i < Math.PI * 2; i += 0.1) {
    const dotPosition = {
        x: circlePosition.x + 150 * cos(i),
        y: circlePosition.y + 150 * sin(i)
    }

    const noiseVal = noise(dotPosition.x * noiseScale, dotPosition.y * noiseScale)
    const newRadius = noiseVal * 150
    const newPosition = {
        x: circlePosition.x + newRadius * cos(i),
        y: circlePosition.y + newRadius * sin(i)
    }
    points.push(newPosition)
}

draw 渲染函数中,我们做了几件事情:

  1. 定了圆形的位置变量circlePosition,将圆心坐标设为画板的中心位置。

  2. 通过遍历弧度 0 到 Math.PI * 2,计算出以 circlePosition 为圆形,以 150 为半径的圆上的点坐标。

  3. const noiseVal = noise(dotPosition.x * noiseScale, dotPosition.y * noiseScale)
    针对每一个圆上的坐标点 A,基于此坐标点计算该点对应的二维噪声值 noiseVal

  4. const newRadius = noiseVal * 150
    基于该噪声值,计算该点对应新的圆的半径值。

  5. 基于新的圆半径算出圆上的点坐标点 B,将点坐标点 B 添加到 points 数组中。

点 A 和点 B 的关系如下图

如此,对于 150 半径的圆上的每一个点 A,都有一个经过二维噪声计算而来的新的点 B,将所有的点 B 连接起来,即可得到一个随机的不规则图形。

#### 流场

针对画板上的每一个坐标点,可以使用二维噪声计算出一个噪声值,将 0 到 1 的噪声值转化为 0 到 Math.PI * 2 的弧度,即可生成一个流场。

见下面的例子

同前面一样,我们计算出每个点的二维噪声值,通过 map 方法将噪声值映射为弧度,以线段的形式将噪声值的变化显示了出来。

## 粒子游走模型

粒子系统在计算机图形、游戏、动画中使用较多,通常用来模拟烟、火、雨、云等自然现象,也可以用于创建抽象艺术或交互式可视化。粒子系统是由一系列的粒子组成,每个粒子都有一些基本的属性,比如大小、位置、速度、加速度、颜色、生命等。本节我们不详细介绍粒子系统的基本概念,只会涉及到粒子在流场中的运动。

如下是一个基本的粒子实现,我们的粒子在重力的作用下做自由落体运动。

在这个简单的粒子模型实现中,我们定义了粒子的位置、速度、加速度和大小,并使用 applyForce 方法对粒子添加重力,从而更新粒子的加速度,驱动粒子的运动。

为了表示粒子的位置、速度、加速度等信息,我们在代码中频繁使用了 createVector 方法,它用来创建一个具有方向和大小的向量。向量是在处理位置、速度、加速度等物理概念时非常有用的工具。在后续的文章中,我们将重点介绍向量及其在粒子系统中的使用。

现在,重点看看我们粒子模型是如何运动起来的。明白了运动原理后,更进一步地,我们将把粒子与流场结合来,将流场的力作用于每个粒子,以实现粒子在流场中的流动。

将粒子代码简化后,我们可以看到以下重点方法和属性

粒子的运动,其实就是对应着粒子位置的变化,在单位时间内,粒子的位置计算方式为 pos = pos + vel,即粒子的位置等于粒子的位置加上粒子的速度。 而速度计算方式为 vel = vel + acc,即粒子的速度等于粒子的速度加上粒子的加速度。加速度的大小取决于受力之和,acc.add(force)

所以基于上述简单的运动模型,我们有了以下的速度和位置计算代码:

// 模拟向下的重力
const force = createVector(0, 0.1)

// 1. 对粒子施力,修改加速度
particle.acc.add(force)

// 2. 更新粒子的速度
particle.vel.add(acc)

// 3. 更新粒子的位置
particle.pos.add(vel)

// 4. 重置加速度为 0
particle.acc.mult(0)

后续我们的粒子运动都是基于此模型进行计算的,如果想要了解此运动模型的由来,可以具体参考 nature of code

从运动模型中,我们可以知道,粒子运动的关键在于对它施加的力,力促使粒子以某种规则进行运动。上面的粒子中,我们基于重力模型,对粒子施加了向下的重力,如此粒子便向下做自由落体运动。如果在每一次渲染时,我们给粒子添加的是流场的力,那么粒子必定会沿着流场的力的方向移动。

结合上面流场的计算,我们可以使用以下的代码

function draw() {
  background(0);
  for (let i = 0; i < particles.length; i++) {
    // 计算当前粒子位置的流场二维噪声
    const noiseVal = noise(particles[i].x * 0.001, particles[i].y * 0.001)
    // 将二维噪声转换为角度
    const radian = map(noiseVal, 0, 1, 0, Math.PI * 2)
    // 将角度转换为力
    const force = p5.Vector.fromAngle(radian)
    particles[i].applyForce(force)
    particles[i].update();
    particles[i].display();
  }
}

是不是已经很接近我们最开始的效果了?我们当前使用了固定的颜色来填充线条的样式,如果想要使用其他的颜色,我们可以设定一个颜色色盘,初始化粒子的时候,动态地从色盘中随机选择一个颜色。

const paletteList = [
    ['#606c38', '#283618', '#fefae0', '#dda15e', '#bc6c25'],
    ['#ffd6ff', '#e7c6ff', '#c8b6ff', '#b8c0ff', '#bbd0ff'],
    ['#ccd5ae', '#e9edc9', '#fefae0', '#faedcd', '#d4a373'],
    ['#f0ead2', '#dde5b6', '#adc178', '#a98467', '#6c584c'],
    ['#0799f2', '#45217c', '#ffffff'],
    ['#ff9f1c', '#ffbf69', '#ffffff', '#cbf3f0', '#2ec4b6']
]

const palette = random(paletteList)
const particleColor = random(palette)
  1. 上面的颜色色盘设置代码中,我们定义了一个二维数组,每个子数组都是预定好的一组颜色。
  2. 我们通过 p5.js 的 random 函数,随机选择了一个颜色组。
  3. 在需要颜色值的时候,我们使用 random 函数,从颜色组中随机选择一个颜色。

## 更进一步

本次教程,我们基于柏林噪声和流场以及初步的粒子模型,实现粒子基于流场的随机游走模型。

接下来,你可以做更多的尝试,比如下面一些可能的点:

  1. 修改 noiseVal 到角度的映射,将 noiseVal 映射到更大的角度值上,看看会发生什么。
  2. 修改粒子的绘制函数,将粒子的大小改成随机值试试。
  3. 将粒子的颜色在渲染时改成随时间变化的值试试呢。
  4. 随机修改粒子的绘制函数,现在是一个圆形,改成随机输出正方形、三角形、线段呢?