代码瀑布(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.js 功能都在全局命名空间中,这意味着我们可以简单直接的调用 ellipse、fill 等函数。然而很多时候,我们都会跟其它的 JavaScript 库进行混合编程。为了应对这种情况,p5.js 可以把所有函数都绑定在一个变量中,而不污染全局的命名空间:
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
}
})
}