用 JavaScript 实现粒子系统和万有引力

本文目标

本文将介绍简单的运动学模拟,以及如何使用 Canvas 实现一个符合直觉的,遵循现实世界物理规则的粒子系统。

本文将假设读者了解:

  1. 基本的 Canvas 绘图函数
  2. ES6 语法

理论基础

设物体在任意时间 t 具有状态:位置 ,速度 ,加速度 ,质量 ,合外力

在计算机系统中,一切都是离散的,因此我们无法真正地模拟连续的真实世界,为此我们取一个足够小的 ,每次我们都从物体当前的 时刻状态,计算物体下一个时刻 的状态,也即:

这里采用了欧拉法,存在准确度和稳定性问题,在此我们先忽略这些问题。

容易得出,计算机模拟的物体运动即是求解每个时刻物体的新位置和新速度,新速度依赖于所受外力或直接更简单的模型下的直接加速度,新位置则依赖于新速度。

接下来我们将实际编码,开始实现系统。

二维空间中的向量 Vector2

我们创建一个 Vector2 类,来表示一个二维空间中的向量,它具有两个指标

在典型的浏览器中的 Canvas 画布中, 表示横坐标、 表示纵坐标,并且画布的左上角是 ,向右 递增,向下 递增。

class Vector2 {
  constructor (x, y) {
    this.x = x
    this.y = y
  }
  // 二维向量的模
  length() {
    return Math.sqrt(this.x * this.x + this.y * this.y)
  }
}

为向量添加基础的运算方法

//  向量的标准化(取单位向量)
normalize() {
  const inv = 1 / this.length()
  return new Vector2(this.x * inv, this.y * inv)
}
// 向量加, v 是另一个 Vector2
add(v) {
  return new Vector2(this.x + v.x, this.y + v.y)
}
// 向量减, v 是另一个 Vector2
subtract(v) {
  return new Vector2(this.x - v.x, this.y - v.y)
}
// 向量数/叉乘, f 是一个数
multiply(f) {
  return new Vector2(this.x * f, this.y * f)
}
// 向量数除, f 是一个数
divide(f) {
  const invf = 1 / f
  return new Vector2(this.x * invf, this.y * invf)
}
// 向量间点乘, v 是另一个 Vector2
dot(v) {
  return this.x * v.x + this.y * v.y
}

注意,所有的运算方法都是不可变的(Immutable),也就是说执行方法后自身[ 和参与运算的其他向量 ]不会发生改变。

为其添加静态属性和方法

Vector2.zero = new Vector2(0, 0) // zero 是坐标原点,也是一个零向量
Vector2.unit = function (x, y) {
  const u = new Vector2(x, y)
  return u.normalize()
}

粒子的抽象 Particle

粒子的位置速度加速度都能抽象成一个 Vector2

有了二维向量,就可以着手构建粒子,在运动模拟中,我们将忽略一个粒子的大多数特征,如惯量、力矩、自旋,添加渲染中必要的颜色属性,并得到一个非常简洁的粒子类:

class Particle {
  constructor(position, velocity, color, mass) {
    this.position = position
    this.velocity = velocity
    this.acceleration = Vector2.zero
    this.color = color
    this.mass = mass
    this.radius = Particle.massToRadius(mass)

    this.dead = false
  }
}

可以看到,粒子由位置速度颜色质量这四个基本要素以及内秉的加速度构成。
位置和速度都是 Vector2 类型,颜色的类型是帮助类 Color(见附录)。

Particle 的半径由它的质量通过如下方式计算而出:

此处我们假设了所有的粒子具有相同的密度以简化计算,并且减少了质量差距带来的半径差距————为了渲染美观。

Particle.massToRadius = function (mass) {
  return Math.pow(mass, 1 / 12)
}

Particle 还有其他静态方法:

Particle.distancePow2 = function (a, b) { // 计算两个粒子的欧几里得距离的平方
  return Math.pow(a.position.x - b.position.x, 2) + Math.pow(a.position.y - b.position.y, 2)
}

到目前为止,我们已经有了 Vector2 和 Particle 两个类,现在我们已经可以尝试实现一些基础的运动过程。

单个粒子的运动

因为加速度可以由合外力和质量简单计算得出,我们考虑最简单的加速度速度模型:单个粒子不受外力的情况。

设我们拥有粒子 p = new Particle(...) ,时间间隔量 dt,具有外部加速度 acceleration

根据上文的公式,我们可以得出两条具体的表达式:

p.position = p.position.add( p.velocity.multiply(dt) )
p.velocity = p.velocity.add( acceleration.multiply(dt) )

实际运算时,习惯上先计算当前的位置,再更新当前的速度。

在每个计算周期,这两条表达式都会运行,更新 p 的位置和速度矢量。

准备 Canvas

有了上面这两条核心的运动逻辑,现在我们只差让粒子动起来的 画布 了。

我们所说的画布是一个 CanvasRenderingContext2D 类型的对象,通常我们用 ctx 来命名它。
画布依赖于它所属的支撑的元素:HTMLCanvasElement 类型的对象,通常被命名为 canvascanvasEle,前者通过后者原型上的方法getContext得到(参数必须是'2d')。

在大多数现代浏览器,尤其是 Chrome 中,Canvas 拥有其他方式无法匹敌的渲染速度和 GPU 加速能力,但是由于 Canvas 较为低级的 API 设计,想用 Canvas 画出像样的东西需要做的准备工作相对要多了那么些。

一个形如 <canvas></canvas> 的闭合标签或 document.createElement('canvas') 的返回值便是一个 HTMLCanvasElement 的实例,在本文的所有实验中,画布元素都是来自一个 <canvas> 标签,画布来自对元素的引用调用 getContext('2d')

通常画布元素 canvasEle 都是以 Html 标签的形式存在于文档流中,这样一来,我们对它的画布 ctx 的所有更新、操作都会实时地被浏览器实时呈现到你的显示器上。

本文中的所有画布在创建时都会先读取 window.devicePixelRatio 并对画布进行缩放。
window.devicePixelRatio 是一个浏览器内置的常量,表示当前显示设备的缩放比例,在高分辨率设备上这个值通常是 2 或 3。

例如在 devicePixelRatio 为 2 的视网膜屏设备上,有一宽高都为 400px 的 canvas ,实际内部的画布尺寸是 800px * 800px

如不做处理,视网膜屏和移动设备上的 canvas 将会一片模糊。

有些情况下,它也可以在某些时候动态地使用 document.createElement('canvas') 创建而不挂载到文档中,这样的仅存活在内存中的 canvasEle 可以帮助解决一些特殊的问题,比如转换图片为 base64 编码,或者给另一个 ctx 加速。

Canvas 具有很多基本的绘图、变换、图像处理和像素控制方法,我们将仅用到 beginPath, arc, closePath, fill 这几个函数,以及 fillStyle 这个属性。

以下是这几个方法和属性的定义:

beginPath 和 closePath

void ctx.beginPath()
void ctx.closePath()

beginPath 是 Canvas 2D API 通过清空子路径列表开始一个新路径的方法。
当你想创建一个新的路径时,调用此方法。

closePath 是 Canvas 2D API 将笔点返回到当前子路径起始点的方法。
它尝试从当前点到起始点绘制一条直线。
如果图形已经是封闭的或者只有一个点,那么此方法不会做任何操作。

arc

void ctx.arc(
  x: number, y: number,
  radius: number,
  startAngle: number, endAngle: number,
  anticlockwise: boolean
)

arc 是 Canvas 2D API 绘制圆弧路径的方法。
圆弧路径的圆心在 位置,半径为 ,根据 anticlockwise(默认为顺时针)指定的方向从 startAngle 开始绘制,到 endAngle 结束。

fill 和 fillStyle

void ctx.fill()
void ctx.fill(fillRule: 'nonzero' | 'evenodd')
void ctx.fill(path: Path2D, fillRule: 'nonzero' | 'evenodd')
ctx.fillStyle = someFillStyle as string | CanvasGradient | CanvasPattern

fill 是 Canvas 2D API 根据当前的填充样式,填充当前或已存在的路径的方法。
采取非零环绕 (nonzero) 或者奇偶环绕 (evenodd) 规则。

fillStyle 是Canvas 2D API 使用内部方式描述颜色和样式的属性。
默认值是 #000 (黑色)。

由此,我们可以给 Particle 添加 render 方法了:

render(ctx) {
  ctx.fillStyle = this.color.toRgba(1)

  ctx.beginPath()
  ctx.arc(this.position.x, this.position.y, this.radius, 0, Math.PI * 2, true)
  ctx.closePath()
  ctx.fill()
}

toRgba 是 Color 的一个方法,接受一个 0 ~ 1 的参数表示 α 通道值,返回一个 rgba 字符串。

试一试,模拟最基本的粒子运动过程

基于上述的理论,也有了 render 方法,这里事先准备好了所需的工具和类,亲自动手试试吧!

PlayGround (这是一个 45° 平抛的例子,尝试修改起始位置、速度或加速度,观察变化)
* ctx 是事先准备好的画布,hdl 接受每次 requestAnimationFrame 的句柄。点击 run 将运行编辑框内的脚本,点击 stop 会销毁定时任务,点击 clear 会清空画布。 使用 requestAnimationFrame 是为了让画布每秒更新 60 次。
** 这里使用了 stroke 来显示粒子的轨迹。

粒子间的引力

当粒子的数量超过 1 个,情况就会变得复杂起来:目前为止我们都没有考虑粒子受力的情况。

万有引力的理论基础

设两个质点的质量分别为 , ,并且在它们之间的距离为 ,则它们之间的万有引力 为:

其中, 是万有引力常数,约为

引力的简化和计算

为了适应我们创建的模型,现实中的一些常数需要在计算机系统中适当地缩放,先前我们在通过粒子的质量计算粒子的体积时已经使用了这样的方法。

现实生活中我们很难察觉引力的作用,因为它太小了,为了使得引力的效应变得清晰可见,我们必须让万有引力常数变得足够大。

在此我们把常数 放大:

const G = 6.67408

由此我们可以计算两个粒子的相互吸引力了:

设有粒子 p1 ,粒子 p2

则万有引力的计算方法为:

const GravitationForce = G * (p1.mass * p2.mass) / Particle.distancePow2(p1, p2)

抽象为函数:

function F_Gravitation(p1, p2) {
  return G * (p1.mass * p2.mass) / Particle.distancePow2(p1, p2)
}

引力和加速度

现在,粒子受到了外力,它将由外力产生动态的加速度,而不是提前预设的固定加速度,我们现在来讨论引力环境下粒子的加速度的计算。

考虑粒子 ,设有粒子的集合
根据第二节的公式,易得:

在实际情况中,粒子集合中是包含将要参与计算的粒子 p 的,写成 TypeScript 代码 :

const particles: Particle[] = [p, ...]

在此直接给出每个计算周期粒子 p 的加速度的计算方法,需要理解的地方添加了注释:

const totalGravitation = particles.reduce(
  (pv /* 从每个其他粒子上累积获得的加速度 */, cv /* 当前参与计算的目标粒子 */) => {
    // 对 particles 的循环会碰到 cv 就是 p 的情况,直接返回 pv
    if (p === cv) {
      return pv
    }
    else {
      // 计算引力加速度的数值大小
      const gravAcc = G * cv.mass / Particle.distancePow2(p, cv)
      // 计算引力加速度的方向,并与 gravAcc 合并为正确的加速度向量
      // 具体方法是先取 CO + OP,即 CO - PO,得到向量 CP,将其单位化
      // 并乘上模 gravAcc,得到目标向量
      const gravVec = Vector2.unit(cv.position.x - p.position.x, cv.position.y - p.position.y).multiply(gravAcc)
      // 累加到 pv
      return pv.add(gravVec)
    }
  },
  Vector2.zero /* reduce 的起始值,因为是累加,所以是零向量 */
)
// 因为我们的模型中粒子仅受引力,故每一刻的加速度就是计算出的引力加速度
p.acceleration = totalGravitation

试一试,模拟粒子间的引力

有了计算引力的公式,我们就可以在下面的实验里直观地观察引力了!

PlayGround (这是一个带初速度的轻粒子被静止的重粒子吸引的例子,尝试修改 G 、初速度、质量或起始位置,观察变化)
* gf 是工具函数,即上面的p的加速度计算方法,gf 依赖外部的 particles

粒子间的碰撞

思考上文介绍的粒子的万有引力计算方法,不难发现有一种情况需要考虑:
当两个粒子特别靠近时, 将变得非常大,表现为两个粒子相互靠近,并以极快的速度弹出。
在下面的实验中释放两个初速度为 0 的粒子便可观察到这一现象。

为了避免这种情况的发生,我们必须引入碰撞检测以及碰撞后的处理。

碰撞的定义

我们规定一个最小距离,当两个粒子的距离小于它们的最小距离,则我们认为发生了碰撞。

在此我们模仿天体物理中的洛希极限 定义本文中的粒子间的洛希极限

其中,, 是两个粒子的半径。

实际计算中大多是取平方结果,于是我们为 Particle 添加静态方法:

Particle.RocheLimitPow2 = function (a, b) {
  return Math.pow((a.radius + b.radius) * 1.0134, 2)
}

碰撞的检测方式是对粒子进行简单的二次遍历。

由于在某些情况下粒子会超出画布范围,但我们仍需要对其进行模拟,所以在此我们不会使用 四叉树 法进行碰撞检测。

举例说明,设第一层遍历到 p ,第二层遍历到 pOther 时:
Particle.distancePow2(p, pOther) < Particle.RocheLimitPow2(p, pOther) 时,便认为碰撞发生。

碰撞后的处理

一般情况下,计算机粒子模拟有以下几种碰撞时的处理方法:

  • 完全球弹性碰撞
  • 完全矩形弹性碰撞
  • 互相穿过
  • 以某种方式合并

既然引入了洛希极限和引力,在本系统中我们将采用最后一种方式处理粒子间的碰撞:

我们设定系统中的粒子过分接近时,重的那个粒子会表现出吸收轻的粒子的行为

即重的粒子完全获得轻粒子的质量动量,并导致轻粒子“死去”。

下面直接给出代码:

class Particle {
...
  devourOther(pOther) {
    if (pOther.mass < this.mass && !this.dead && !pOther.dead) {
      this.velocity = this.velocity
        .multiply(this.mass)
        .add(pOther.velocity.multiply(pOther.mass))
        .divide(this.mass + pOther.mass)

      this.mass += pOther.mass
      this.radius = Particle.massToRadius(this.mass)

      this.color = this.color.add(pOther.color)
      pOther.dead = true
    }
  }
...
}

可以注意到,重的粒子 "吸收" 过于靠近的轻的粒子这一过程是符合动量守恒的。

粒子吸收时颜色也会发生合并

粒子系统 ParticleSystem

我们完成了粒子类的构建,现在我们要引入粒子系统的概念。

粒子系统用来管理并模拟大量的粒子,抽象一些粒子间的运算和状态变化,降低代码复杂度和耦合度。

我们的粒子系统包含以下几个部分:

  • 粒子的容器
  • 万有引力模拟
  • 运动模拟
  • 粒子间的互动
  • 游戏循环
  • 粒子的激发(新增粒子)
  • 粒子的消亡(移除粒子)
  • 能容纳可插拔的特效,如完全弹性的围栏

由此我们就可以将上文的万有引力计算逻辑、粒子的运动模拟等搬到粒子系统中。

以下是粒子系统的实现:

class ParticleSystem {
  static G = 6.67408

  constructor(ctx, w, h) {
    this.w = w || ctx.canvas.width / window.devicePixelRatio
    this.h = h || ctx.canvas.height / window.devicePixelRatio
    this.particles = []
    this.context = ctx
    this.effectors = []
  }
  simulate(dt) {
    this.applyEffectors()
    this.devour()
    this.universalGravitation()
    this.kinematics(dt)
  }
  render() {
    for (const p of this.particles) {
      p.render(this.context)
    }
  }
}

上述的代码创建了粒子系统的基础框架。

构造函数接受 1 个或 3 个参数:CanvasRenderingContext2D 类型的 ctx 是粒子系统依赖的渲染上下文,wh 是粒子的宽高范围,如果未指定则取 ctx逻辑宽高(目前未使用到)。

核心逻辑

render

render 方法完成一次全体粒子的绘制。

render 没有主动刷新 ctx ,如果不在外部使用 ctx.clearRect 方法刷新画布则会出现重影。
之所以这么做是因为之后使用 OffscreenCanvas 等技术优化画布速度后无需再做刷新。

simulate

simulate 方法完成一次全体粒子的运动及其他模拟和操作,参数 dt 为时间间隔量,内部涉及了 4 个方法,我们逐一分析:

applyEffectors

applyEffectors() {
  for (const effector of this.effectors)
    for (const p of this.particles)
      effector.apply(p)
}

applyEffectors 方法逐个计算效果器。

effectors 是一个用于存放效果器插件的容器,每个插件都有 apply 方法,其接收一个参数 p: Particle
方法将遍历所有插件,并将效果应用到每个粒子上。

devour

devour() {
  this.particles.sort((a, b) => b.mass - a.mass).forEach((p, selfIndex, particlesRef) => {
    const foodCandidateIndex = []

    particlesRef
      .filter((pOther, innerIndex) => {
        if (p.mass <= pOther.mass) return false
        if (p === pOther) return false

        const canEat = Particle.distancePow2(p, pOther) < Particle.RocheLimitPow2(p, pOther)
        if (canEat) {
          foodCandidateIndex.push(innerIndex)
        }
        return canEat
      })
      .forEach(foodP => p.devourOther(foodP))

    foodCandidateIndex.forEach(beEatenIndex => this.remove(beEatenIndex))
  })

  this.particles.forEach((p, index) => {
    if (p.dead) this.remove(index)
  })
}

devour 方法先将所有粒子按照质量排序,从最重的开始循环,在下一层循环中寻找可以吸收的轻粒子,标记它们并使用前述的 devourOther 方法吸收。

universalGravitation

universalGravitation() {
  this.particles.forEach((p, index) => {
    const totalGravitation = this.particles.reduce((pv, cv, innerIndex) => {
      if (p === cv) {
        return pv
      }
      else {
        const gravAcc = ParticleSystem.G * cv.mass / Particle.distancePow2(p, cv)
        const gravVec = Vector2.unit(cv.position.x - p.position.x, cv.position.y - p.position.y).multiply(gravAcc)
        return pv.add(gravVec)
      }
    }, Vector2.zero)

    p.acceleration = totalGravitation
  })
}

universalGravitation 方法对每个粒子应用这里给出的算法计算引力下的加速度。

kinematics

kinematics(dt) {
  for (const particle of this.particles) {
    particle.position = particle.position.add(particle.velocity.multiply(dt))
    particle.velocity = particle.velocity.add(particle.acceleration.multiply(dt))
  }
}

kinematics 方法对每个粒子应用这里讨论的算法进行运动模拟。


这里还有两个基础方法 emitremove,他们封装了粒子的激发消亡
一般来说, remove 提供给粒子系统内部调用
emit 提供给外部调用

粒子一旦被创建并 emit 入粒子系统,就应当仅受到粒子系统内部控制

emit(particle) {
  this.particles.push(particle)
}
remove(index) {
  if (!this.particles[index] || !this.particles[index].dead) {
    return
  }
  this.particles[index] = this.particles[this.particles.length - 1]
  this.particles.pop()
}

remove 中使用了一个小技巧
particles 是无序的,所以我们可以让数组的最后一位和待删除的元素进行交换,再使用 pop 即可完成删除操作。

试一试,使用粒子系统

PlayGround (这是一个完整的粒子系统的例子,可以看到每次绘图前会手动清空画布,尝试注释 ctx.clearRect... ,观察变化)
* random 是随机数产生器,接受两个参数:上界和下界

弹性围墙

目前为止还有一点比较糟糕:粒子很容易跑出画布的范围,然后我们就看不见它们了。

我们在粒子系统中预留了名为 effectors 的效果插件容器,现在我们可以着手构建我们的第一个插件:弹性盒室

class ChamberBox {
  constructor(x1, y1, x2, y2, elasticCoefficient) {
    elasticCoefficient = elasticCoefficient || 1

    this.apply = particle => {
      if (particle.position.x - particle.radius < x1 || particle.position.x + particle.radius > x2) {
        particle.velocity.x = -1 * elasticCoefficient * particle.velocity.x
      }
      if (particle.position.y - particle.radius < y1 || particle.position.y + particle.radius > y2) {
        particle.velocity.y = -1 * elasticCoefficient * particle.velocity.y
      }
    }
  }
}

ChamberBox 是一个类,实例只有一个函数 apply,接受一个 Particle 类型参数 particle ——符合 effectors 的规范。

简单来说,ChamberBox 的作用就是让碰到内壁的粒子翻转该方向的速度分量。
可选的 elasticCoefficient 参数可以传入小于 1 的数,实际上它就是围墙的弹性系数,当未传递或传入 1 时,围墙是完全弹性的。

外部场

我们介绍第二个效果器:外部场

export class Field {
  constructor(dt, fieldAcc) {
    this.apply = particle => {
      particle.velocity = particle.velocity.add(fieldAcc(particle.position).multiply(dt))
    }
  }
}

Field 是一个外部的加速度场,对出于其中的粒子施加额外的加速度。
其中,第二个参数 fieldAcc 有点难以理解,这是它的 TypeScript 定义:

(position: Vector2) => Vector2

实际上 fieldAcc 本质上是一个 Vector2 到 Vector2 的映射 。 即 fieldAcc 接受每个粒子的当前位置矢量,并给出这个位置的场矢量,施加于此粒子。

一个最简单的粒子就是竖直向下的均匀场(重力)了:

const Gravity = new Field(0.01, () => new Vector2(0, 9.8))

当然也可以构造复杂的向心漩涡场:

const Vortex = new Field(0.01, position => {
  const center = new Vector2(200, 200)
  return center.subtract(position).multiply(random(.3, .6))
})

dt 一般设置为 ParticleSystem 模拟时的值

试一试,使用效果器

现在我们在上一个例子中加入 ChamberBoxField,看看效果吧!

PlayGround (尝试修改 ChamberBox 的弹性系数,观察变化)

离屏渲染

作为本文的最后一部分,这里将介绍一种常用的性能优化手段:离屏渲染,实际上属于双缓冲这样一种古老而实用的优化方法。

通过将原先零碎的 Canvas 操作集中到一块内存中的不可见画布,即离屏画布上,再于每一帧刷新之际将离屏画布的内容或移动,或复制到主画布上,完成渲染。
在各种性能测试中,只要绘图任务稍多,使用离屏渲染的情况下帧数就会得到非常明显的提高。

我们将介绍两种离屏渲染的环境,一种适合仅面向最新的浏览器的情况,一种适合适配大多数主流浏览器的情况:

目前最新也是最高效的做法是使用浏览器提供的 API OffscreenCanvas 来完成离屏环境的创建。
遗憾的是除了 Chrome 外,其他浏览器对其的支持参差不齐,部分浏览器中的 OffscreenCanvas 仅可以创建用于 WebGL 的画布,另一些浏览器甚至根本没有这个 API。
除此以外,还需要从 HTMLCanvasElement 创建 ImageBitmapRenderingContext 并用作主画布。

如果用这样的方式渲染,在每帧的绘图都收集到离屏画布后,主画布会直接从离屏画布移动走其中的内容(以ImageBitmap)的方式,并呈现到显示设备。
这样的方式避免了一次繁重的图像拷贝,并且因为 ImageBitmap 是一种 GPU 资源,绘图的速度也被缩短。

Firefox 目前 (v69.0) 可以通过在 about:config 将 gfx.offscreencanvas.enabled 选项设置为 true 开启 OffscreenCanvas,但其仍然只支持 WebGL。

Safari 目前 (12.1.2) 可以通过在 开发-实验性功能 打开 ImageBitmap 和 OffscreenCanvas 开启 OffscreenCanvasImageBitmapRenderingContext,尽管可以在 window 中访问到 OffscreenCanvasRenderingContext2D 构造函数,但是其仍不支持 OffscreenCanvas.getContext('2d')

下面的组件会显示你正在浏览的浏览器对上述功能的支持情况,如果都为绿色则表示全部支持。

以下是创建这个环境的代码:

OffscreenCanvas 和 ImageBitmapRenderingContext









 


 




 




function initAdvancedCanvasContexts(width, height, parentEle, canvasElePre) {
  const dpi = window.devicePixelRatio

  const canvasEle = canvasElePre || document.createElement('canvas')
  canvasEle.style.width = width + 'px'
  canvasEle.style.height = height + 'px'
  canvasEle.width = width * dpi
  canvasEle.height = height * dpi
  const ctx = canvasEle.getContext('bitmaprenderer')
  canvasElePre || parentEle.appendChild(canvasEle)

  const canvasOff = new OffscreenCanvas(width * dpi, height * dpi)
  const ctxOff = canvasOff.getContext('2d')
  ctxOff.scale(dpi, dpi)

  ctx._paintFromOffscreen = function () {
    this.transferFromImageBitmap(canvasOff.transferToImageBitmap())
  }
  return [ctx, ctxOff, canvasEle, canvasOff]
}

针对传统浏览器









 


 






 
 
 




function initClassicCanvasContexts(width, height, parentEle, canvasElePre) {
  const dpi = window.devicePixelRatio

  const canvasEle = canvasElePre || document.createElement('canvas')
  canvasEle.style.width = width + 'px'
  canvasEle.style.height = height + 'px'
  canvasEle.width = width * dpi
  canvasEle.height = height * dpi
  const ctx = canvasEle.getContext('2d')
  canvasElePre || parentEle.appendChild(canvasEle)

  const canvasOff = document.createElement('canvas')
  canvasOff.width = width * dpi
  canvasOff.height = height * dpi
  const ctxOff = canvasOff.getContext('2d')
  ctxOff.scale(dpi, dpi)

  ctx._paintFromOffscreen = function () {
    this.clearRect(0, 0, width * dpi, height * dpi)
    this.drawImage(canvasOff, 0, 0)
    ctxOff.clearRect(0, 0, width, height)
  }
  return [ctx, ctxOff, canvasEle, canvasOff]
}

以上两个函数都接受 4 个参数:画布父元素主画布元素,最后两个参数是可选的,两种方法的主要区别已经标出。
函数将完成画布、离屏元素、离屏画布的创建和针对视网膜屏幕的处理,并保证主画布元素挂载到了父元素上。
随后在主画布 ctx 上新建了一个名为 _paintFromOffscreen 的方法,以此将两个画布相关联。

在我们的粒子系统中,是否使用离屏渲染带来的性能影响并不是很大,当粒子数量非常多时,大量的 CPU 时间都花费在了计算引力碰撞上。

本文将不此进行展开,以后可能会有专门文章对算法优化和多线程计算等进行讨论。

附录

本文涉及到的源代码

Vector2

展开

export default class Vector2 {
  static zero = new Vector2(0, 0)
  static unit(x, y) {
    const u = new Vector2(x, y)
    const dvd = u.length()
    return u.divide(dvd)
  }
  static isNaV(vec) {
    return isNaN(vec.x) || isNaN(vec.y)
  }
  constructor (x, y) {
    this.x = x
    this.y = y
  }
  length() {
    return Math.sqrt(this.x * this.x + this.y * this.y)
  }
  normalize() {
    var inv = 1 / this.length()
    return new Vector2(this.x * inv, this.y * inv)
  }
  add(v) {
    return new Vector2(this.x + v.x, this.y + v.y)
  }
  subtract(v) {
    return new Vector2(this.x - v.x, this.y - v.y)
  }
  multiply(f) {
    return new Vector2(this.x * f, this.y * f)
  }
  divide(f) {
    var invf = 1 / f
    return new Vector2(this.x * invf, this.y * invf)
  }
  rotate(angle, center) {
    return new Vector2(
      (this.x - center.x) * Math.cos(angle) - (this.y - center.y) * Math.sin(angle) + center.x,
      (this.x - center.x) * Math.sin(angle) + (this.y - center.y) * Math.cos(angle) + center.y
    )
  }
}

Particle

展开

import Vector2 from './vector2'

export default class Particle {
  static distancePow2(a, b) {
    return Math.pow(a.position.x - b.position.x, 2) + Math.pow(a.position.y - b.position.y, 2)
  }
  static massToRadius(mass) {
    return Math.pow(mass, 1 / 12)
  }
  static RocheLimitPow2(a, b) {
    return Math.pow((a.radius + b.radius) * 1.0134, 2)
  }
  constructor(position, velocity, color, mass, stasis = false, obWidth = 960, obHeight = 960) {
    this.position = position
    this.velocity = velocity
    this.acceleration = Vector2.zero
    this.color = color
    this.mass = mass
    this.radius = Particle.massToRadius(mass)

    this.stasis = stasis
    this.visible = true

    this.dead = false
    this.isSelected = false

    this.obWidth = obWidth
    this.obHeight = obHeight
  }
  devourOther(pOther) {
    if (pOther.mass < this.mass && !this.dead && !pOther.dead) {
      // m1 * v1 + m2 * v2 = (m1 + m2) * v'
      const newVelovity = this.velocity.multiply(this.mass).add(pOther.velocity.multiply(pOther.mass)).divide(this.mass + pOther.mass)
      this.velocity = newVelovity
      this.mass += pOther.mass
      this.radius = Particle.massToRadius(this.mass)
      this.color = this.color.add(pOther.color)
      pOther.dead = true
    }
  }
  outOfScreen() {
    return this.position.x + this.radius < 0 || this.position - this.radius > this.obWidth ||
      this.y + this.radius < 0 || this.y - this.radius > this.obHeight
  }

  render(ctx) {
    ctx.fillStyle = this.color.toRgba(1)

    ctx.beginPath()
    ctx.arc(this.position.x, this.position.y, this.radius, 0, Math.PI * 2, true)
    ctx.closePath()
    ctx.fill()
  }

  complexRender(ctx) {

    for (let i = 1; i <= Math.ceil(this.radius * 2); i += 1) {
      ctx.beginPath()
      ctx.arc(this.position.x, this.position.y, i, 0, Math.PI * 2)
      ctx.closePath()
      ctx.strokeStyle = this.color.toRgba(Math.random())
      ctx.stroke()
    }
  }

  renderPath() {
  }
}

ParticleSystem

展开

import Particle from './particle'
import Vector2 from './vector2'

/**
 * 标准正态分布
 */
function standardNormalDistribution() {
  const numberPool = []
  return function () {
    if (numberPool.length > 0) {
      return numberPool.pop()
    }
    else {
      const u = Math.random(), v = Math.random()
      const p = Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v)
      const q = Math.sqrt(-2 * Math.log(u)) * Math.sin(2 * Math.PI * v)
      numberPool.push(q)
      return p
    }
  }()
}

/**
 * 正态分布
 * @param {number} off 期望分布顶点离开数轴中心的偏移量
 * @param {number} con 相对标准正态分布的系数
 */
function NormalDistribution(off, con) {
  const standard = standardNormalDistribution()
  return standard * con + off
}

export default class ParticleSystem {
  static G = 6.67408

  constructor(ctx, w, h) {
    this.w = w || ctx.canvas.width / window.devicePixelRatio
    this.h = h || ctx.canvas.height / window.devicePixelRatio

    this.workers = []
    /** @type {Particle[]} */
    this.particles = []
    /** @type {CanvasRenderingContext2D} */
    this.context = ctx
    this.effectors = []

    this.pauseSignal = false
    this.disableDevour = false
    this.disableGravitation = false

    this.lastRenderTime = new Float64Array(512)
    this.renderInterval = 0
  }

  emit(particle) {
    this.particles.push(particle)
  }
  emitMess(count, centerMass) {
    const __width = this.w
    const __height = this.h

    const O = new Vector2(__width / 2, __height / 2)
    const R = Math.min(__height, __width) / 2 - 50

    const ct = new Particle(O, Vector2.zero, Color.red, centerMass, true)
    ct.visible = false
    this.emit(ct)

    for (let i = 0; i < count; i++) {
      const randomX = random(__width / 2 - R, __width / 2 + R)
      const randomY = random(__height / 2 - Math.pow(R * R - Math.pow(randomX - __width / 2, 2), 0.5), Math.pow(R * R - Math.pow(randomX - __width / 2, 2), 0.5) + __height / 2)
      const P = new Vector2(randomX, randomY)
      this.emit(new Particle(
        P,
        O.subtract(P).rotate(Math.PI / 2, Vector2.zero).normalize().multiply(NormalDistribution(400, 200)),
        Color.random(),
        random(10, 1000)
      ))
    }
  }

  remove(index) {
    if (!this.particles[index] || !this.particles[index].dead) {
      return
    }
    this.particles[index] = this.particles[this.particles.length - 1]
    this.particles.pop()
  }

  kinematics(dt) {
    for (const particle of this.particles) {
      if (particle.stasis) {
        particle.acceleration = particle.velocity = Vector2.zero
        continue
      }
      particle.position = particle.position.add(particle.velocity.multiply(dt))
      particle.velocity = particle.velocity.add(particle.acceleration.multiply(dt))
    }
  }

  devour() {
    this.particles.sort((a, b) => b.mass - a.mass).forEach((p, _tmp, particlesRef) => {

      const foodCandidateIndex = []

      particlesRef.filter((pOther, innerIndex) => {

        if (p.mass <= pOther.mass) return
        if (p === pOther) return

        const canEat = Particle.distancePow2(p, pOther) < Particle.RocheLimitPow2(p, pOther)

        if (canEat) {
          foodCandidateIndex.push(innerIndex)
        }

        return canEat
      }).forEach(foodP => p.devourOther(foodP))
      
      foodCandidateIndex.forEach(beEatenIndex => this.remove(beEatenIndex))
    })
    
    this.particles.forEach((p, index) => {
      if (p.dead) this.remove(index)
    })
  }

  universalGravitation() {
    this.particles.forEach((p) => {

      const totalGravitation = this.particles.reduce((pv, cv) => {
        if (p === cv) {
          return pv
        }
        else {
          const gravAcc = ParticleSystem.G * cv.mass / Particle.distancePow2(p, cv)
          const gravVec = Vector2.unit(cv.position.x - p.position.x, cv.position.y - p.position.y).multiply(gravAcc)
          return pv.add(gravVec)
        }
      }, Vector2.zero)

      p.acceleration = totalGravitation
    })
  }

  applyEffectors() {
    for (const effector of this.effectors) {
      const apply = effector.apply
      for (const p of this.particles) apply(p)
    }
  }

  simulate(dt) {
    if (this.pauseSignal) return

    this.applyEffectors()
    if (!this.disableDevour) this.devour()
    if (!this.disableGravitation) this.universalGravitation()
    this.kinematics(dt)
  }

  recordRenderTime() {
    const now = performance.now()
    const zeroIndex = this.lastRenderTime.findIndex(v => v === 0)
    const actualLength = zeroIndex === -1 ? this.lastRenderTime.length : zeroIndex + 1

    if (zeroIndex === -1) {
      this.lastRenderTime.set(this.lastRenderTime.subarray(1))
      this.lastRenderTime.set([now], this.lastRenderTime.length - 1)
    }
    else {
      this.lastRenderTime.set([now], zeroIndex)
    }

    if (actualLength < 100) {
      this.renderInterval = (now - this.lastRenderTime[0]) / actualLength
    }
    else {
      this.renderInterval = (now - this.lastRenderTime[actualLength - 100]) / 100
    }
  }

  render() {
    for (const p of this.particles) {
      if (p.outOfScreen() || !p.visible) continue
      p.render(this.context)
    }
  }

  complexRender() {
    for (const p of this.particles) {
      p.complexRender(this.context)
    }

    this.recordRenderTime()
  }
}

Effectors

展开

import Vector2 from './vector2'

export class ChamberBox {
  constructor(x1, y1, x2, y2, elasticCoefficient) {
    elasticCoefficient = elasticCoefficient || 1
    this.apply = particle => {
      if (particle.position.x - particle.radius < x1 || particle.position.x + particle.radius > x2) {
        particle.velocity.x = -1 * elasticCoefficient * particle.velocity.x
      }
      if (particle.position.y - particle.radius < y1 || particle.position.y + particle.radius > y2) {
        particle.velocity.y = -1 * elasticCoefficient * particle.velocity.y
      }
    }
  }
}

export class Field {
  /**
   * @param {number} dt
   * @param {(pos: Vector2) => Vector2} fieldAcc
   */
  constructor(dt, fieldAcc) {
    fieldAcc = fieldAcc || (() => new Vector2(0, 9.8))
    this.apply = particle => {
      particle.velocity = particle.velocity.add(fieldAcc(particle.position).multiply(dt))
    }
  }
}

其他帮助类

展开

export function random(lower, upper) {
  return lower + (upper - lower) * Math.random()
}

export class Color {
  static black = new Color(0, 0, 0)
  static white = new Color(1, 1, 1)
  static red = new Color(1, 0, 0)
  static green = new Color(0, 1, 0)
  static blue = new Color(0, 0, 1)
  static yellow = new Color(1, 1, 0)
  static cyan = new Color(0, 1, 1)
  static purple = new Color(1, 0, 1)
  static random() {
    return new Color(random(.15, .9), random(.1, .9), random(.2, .9))
  }
  constructor(r, g, b) {
    if (r > 1 || g > 1 || b > 1) {
      r = r / 255
      g = g / 255
      b = b / 255
    }
    this.r = r
    this.g = g
    this.b = b
  }
  add(c) {
    return new Color(Math.min((this.r + c.r) / 2, 1), Math.min((this.g + c.g) / 2, 1), Math.min((this.b + c.b) / 2, 1))
  }
  toRgba(alpha) {
    return `rgba(${Math.floor(this.r * 255)},${Math.floor(this.g * 255)},${Math.floor(this.b * 255)},${alpha})`
  }
  toReversedRgba(alpha) {
    return `rgba(${Math.floor((1 - this.r) * 255)},${Math.floor((1 - this.g) * 255)},${Math.floor((1 - this.b) * 255)},${alpha})`
  }
}