代码瀑布Matrix Digital Rain)又称为母体代码(Matrix Code),是电影《黑客帝国》中出现的电脑代码,在影片中经常表示为向下流动的绿色字符。我在参考了 AngeloGiacco/matrix 的设计后,利用 p5.js 在浏览器的上实现了代码瀑布的效果。

我的代码放在了我的 Github 仓库 davepkxxx/matrix_digital_rain 里。我还为其在 Wallpaper Engine 里制作了壁纸,可以直接访问我在创意工坊里的壁纸项目 Matrix Digital Rain 进行订阅。

克隆代码之后命令行输入 npm run build 便会利用 rollup 打包好代码,入口文件是 public/index.html,推荐使用 Chrome 或者 Edge 的最新版本打开它。

p5.js 入门

p5.js 是个 JavaScript 创意编程程式库,它拥有完整的绘画功能,这包括了 HTML5 物件如文字、输入框、视屏、摄像头及音频。入门 p5.js 最简单的方法是使用 p5.js 在线编辑器,在编辑器中可以看到如下代码:

function setup () {
  createCanvas(400, 400)
}

function draw () {
  background(220)
}

钩子函数 setup 将在程序开始时被调用一次。它可以被用来定义初始的环境属性如荧幕大小、背景颜色及媒体加载如图像及字体。在 setup 函数中我们使用了 createCanvas 函数创造一个画布元素,并以像素定义其大小。

钩子函数 draw 会在 setup 之后被调用,该函数将持续地重复执行其中的代码直到该程式终止或当 noLoop 函数被调用。在 draw 函数中我们使用了 background 函数设定了 Canvas 画布的背景颜色,background 函数可以接受多种参数作为颜色:

// 灰度值
background(51)
// RGB 值
background(255, 204, 0)
// HSB 值
colorMode(HSB)
background(255, 204, 100)
// 颜色名字
background('red')
// 3 位数的 16 进制 RGB 值
background('#fae')
// 16 进制 RGB 值
background('#222222')
// RGB 函数
background('rgb(0,255,0)')
// RGBA 函数
background('rgba(0,255,0, 0.25)')
// 百分比 RGB 函数
background('rgb(100%,0%,10%)')
// 百分比 RGBA 函数
background('rgba(100%,0%,100%,0.5)')
// p5.js 的 color 函数
background(color(0, 0, 255))

默认情况下,所有的 p5.j​​s 功能都在全局命名空间中,这意味着我们可以简单直接的调用 ellipse、fill 等函数。然而很多时候,我们都会跟其它的 JavaScript 库进行混合编程。为了应对这种情况,p5.j​​s 可以把所有函数都绑定在一个变量中,而不污染全局的命名空间:

new p5(p => {
  p.setup = function () {
    p.createCanvas(400, 400)
  }

  p.draw = function () {
    p.background(220)
  }
})

代码瀑布的设计

我们在全局中定义了两个变量 frames 和 streams,frames 用于记录当前帧数, streams 是代码瀑布的容器。

let frames = 0
let streams = []

setup 函数

在 setup 函数中我们定义了画布的大小、背景色和使用的字体。和电影中一样,我们要在代码瀑布中显示日文假名,所以我们使用了游朝体作为渲染字体。

function setup () {
  createCanvas(window.innerWidth, window.innerHeight)
  background(0)
  textFont('Yu Gothic')
  textSize(12)
}

draw 函数

在 draw 函数中我们会在每一帧时重新绘制背景。每 5 帧我们增加一条代码流,每 3 帧为代码流增加一个字母,每 15 帧(是“增加代码流”的帧数和“增加代码流中字母”的帧数的公倍数)我们重置一次帧的计数。

为了性能会在循环渲染每一个代码流的时候计算它的下一次变化。渲染完成后会在代码瀑布的容器中移除已经消失的代码流。

function draw () {
  background(0)
  // add steam
  if (frames % 5 === 0) {
    const stream = createStream()
    if (stream) streams.push(stream)
  }
  // render
  streams = streams.filter((e, i) => {
    render(e.symbols)
    if (frames % 3 === 0) nextTick(e)
    return !e.hidden
  })
  // next frame
  if (frames < 15) { frames++ } else { frames = 0 }
}

创建代码流

首先我们生成代码流的 X 轴坐标,然后在这条新代码流中创建它的第一个字符。在 Canvas 中有过多的代码流时会放弃创建新的代码流。

function createStream () {
  const x = createStreamX()

  if (x >= 0) {
    const symbol = createSymbol({ x })
    return {
      x,
      symbols: [symbol],
      first: symbol,
      last: symbol,
    }
  } else return null
}

创建代码流的 X 轴坐标

根据文字大小把 Canvas 分成若干列,随机选取其中的一列 X 轴坐标作为代码流的 X 轴坐标。在该列已有代码流并且该代码流的第一个字母并未消失时,就新随机一个代码流的 X 轴坐标。如果随机 10 次都被占用了,就说明 Canvas 中有过多的代码流,这时我们放弃创建新的代码流。

function createStreamX () {
  for (let i = 0; i < 10; i++) {
    const x = Math.floor(Math.random() * (window.innerWidth / 12)) * 12
    if (streams.every(e => e.x !== x || e.first.hidden)) return x
  }
  return -1
}

创建代码流里的字符

生成代码流字符的参数,用于在 render 函数里渲染到 Canvas 上,默认值都可以被参数 inital 里的数据替换。同电影中一样,代码流里的第一个字符会显示为白色,而后在 nextTick 函数中会被改为绿色。

function createSymbol (inital) {
  return {
    x: 0,
    y: 12,
    text: createText(),
    color: color(255),
    ...inital,
  }
}

创建显示的代码文字

显示的代码文字选取了数字、拉丁字母和日文片假名,和《黑客帝国》电影中一致。

function createText () {
  let r = Math.floor(Math.random() * (10 + 26 * 2 + 90))
  let c
  if (r < 10) { c = 0x0030 + r } else { r -= 10 } // digits
  if (!c && r < 26) { c = 0x0041 + r } else { r -= 26 } // upper-latin
  if (!c && r < 26) { c = 0x0061 + r } else { r -= 26 } // lower-latin
  if (!c) c = 0x30a1 + r // katakana
  return String.fromCharCode(c)
}

渲染代码文字

根据字符配置在 Canvas 中渲染代码文字。为了减少消耗,已经消失的文字不会被渲染。因为数字、字母和假名的宽度不一,所以把对齐模式设置为居中。

function render (symbols) {
  symbols.forEach(e => {
    if (!e.hidden) {
      fill(e.color)
      textAlign(CENTER)
      text(e.text, e.x, e.y)
    }
  })
}

代码流变化

在每一次代码流变化中,都会让代码流的文字渐隐。当代码流里最后一个隐藏字符的 Y 轴坐标超过了 Canvas 的高度,就会把这条代码流标记为已经消失。如果这个代码流没有消失,就会给它增加新的字符。

function nextTick (stream) {
  fadeOut(stream)
  // next symbol
  if (!stream.lastHidden || stream.lastHidden.y < window.innerHeight) {
    const symbol = createSymbol({ x: stream.last.x, y: stream.last.y + 12 })
    stream.symbols.push(symbol)
    stream.last = symbol
  } else {
    stream.hidden = true
  }
}

代码流渐隐

每次代码流发生变化都会让代码流里的字符透明度降低。如果这个字符透明度归零,就会把它标记为隐藏,并且记录代码流中最后一个隐藏的字符。

参考电影,在代码流发生变化时,我会让代码流中的每一个字符有 3% 的概率变成其他文字。

function fadeOut (stream) {
  stream.symbols.forEach(e => {
    // change text
    if (Math.random() < 0.03) e.text = createText()
    // change alpha
    const a = alpha(e.color) - 10
    e.color = color(0, 255, 70, a)
    // hide
    if (a <= 0) {
      e.hidden = true
      stream.lastHidden = e
    }
  })
}