Skip to content

Hello, World!

https://github.com/rustwasm/wasm_game_of_life

Hello World

1
2
3
4
5
➜  Desktop cargo generate --git https://github.com/rustwasm/wasm-pack-template

🤷  Project Name: wasm-game-of-life
🔧   Creating project called `wasm-game-of-life`...
✨   Done! New project created /Users/nocilantro/Desktop/wasm-game-of-life

项目内容:

1
2
3
4
5
6
7
8
9
├── Cargo.toml
├── LICENSE_APACHE
├── LICENSE_MIT
├── README.md
├── src
│   ├── lib.rs
│   └── utils.rs
└── tests
    └── web.rs

Cargo.toml文件是cargo的 指定依赖项和元数据,Cargo 则是 Rust 的包管理器和构建工具。
这个预先配置了一个 wasm-bindgen 依赖项,我们将在后面深入研究一些可选的依赖项,和正确初始化 crate-type 以生成 .wasm 库。

src/lib.rs:

Rust 包的根,我们要编译成 WebAssembly 的。
它用wasm-bindgen与 JavaScript 交互。
它导入了window.alert的 JavaScript 函数,并导出为这个名为greet的 Rust 函数,用于警告(alert)一个问候(greet)消息。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
mod utils;

use wasm_bindgen::prelude::*;

// When the `wee_alloc` feature is enabled, use `wee_alloc` as the global
// allocator.
#[cfg(feature = "wee_alloc")]
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;

#[wasm_bindgen]
extern "C" {
    fn alert(s: &str);
}

#[wasm_bindgen]
pub fn greet() {
    alert("Hello, wasm-game-of-life!");
}

src/utils.rs:
提供了常用的实用函数,让 Rust 编译成 WebAssembly 来得更容易

构建项目

我们用wasm-pack,是根据以下构建步骤:

  • 确保我们有 Rust 1.30 或打上版本,通过rustup安装wasm32-unknown-unknown目标(target),
  • 用cargo将我们的 Rust 的源 编译为 WebAssembly 的 .wasm二进制文件,
  • 为 Rust 生成的 WebAssembly 使用wasm-bindgen,生成 JavaScript API.

要完成所有这些操作,请在项目目录中,运行此命令:

1
wasm-pack build

构建完成后,我们可以看到在pkg目录中,有这些内容:

1
2
3
4
5
6
├── README.md
├── package.json
├── wasm_game_of_life.d.ts
├── wasm_game_of_life.js
├── wasm_game_of_life_bg.d.ts
└── wasm_game_of_life_bg.wasm

README.md文件是从主项目复制的,但其他文件是新来的

pkg/wasm_game_of_life_bg.wasm
该文件是 Rust 编译器从 Rust 源生成的 WebAssembly 二进制文件。其包含 wasm 编译版本(具有所有 Rust 函数和数据)。例如,它具有导出的greet函数

pkg/wasm_game_of_life.js:
该 .js 文件是 wasm-bindgen 生成的,并包含 JavaScript 胶水,其用于将 DOM 和 JavaScript 函数导入 Rust,并向 WebAssembly 函数公开一个很好的 API 到 JavaScript。例如,有一个 JavaScript 的greet函数,其包裹着从 WebAssembly 模块导出的greet函数。就现在而言,这胶水并没有做太多,但是当我们开始在 wasm 和 JavaScript 之间,来回传递更多有趣的值时,它将有助于将这些值传过边界

1
2
3
4
5
import * as wasm from './wasm_game_of_life_bg.wasm';
//...
export function greet() {
    wasm.greet();
}

pkg/wasm_game_of_life.d.ts:

.d.ts文件包含给 JavaScript 胶水的 TypeScript 类型声明。
如果您使用的是 TypeScript,可以检查对 WebAssembly 函数的调用类型,并且您的 IDE 可以题哦那个自动完成和建议!如果您不使用 TypeScript,则可以安全地忽略此文件。

1
export function greet(): void;

pkg/package.json:
包含有关生成的 JavaScript 和 WebAssembly 包的元数据。
npm 和 JavaScript 捆绑包使用它来确定包,包名,版本和一堆其他东西之间的依赖关系。
它帮助我们与 JavaScript 工具继承,并允许我们将我们的包发布到 npm

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
{
  "name": "wasm-game-of-life",
  "collaborators": [
    "zhaoyz <954241552@qq.com>"
  ],
  "version": "0.1.0",
  "files": [
    "wasm_game_of_life_bg.wasm",
    "wasm_game_of_life.js",
    "wasm_game_of_life.d.ts"
  ],
  "module": "wasm_game_of_life.js",
  "types": "wasm_game_of_life.d.ts",
  "sideEffects": false
}

生成静态文件

wasm-game-of-life目录中,运行此命令:

1
2
3
4
➜  wasm-game-of-life git:(master) ✗ npm init wasm-app www

npx: 1 安装成功,用时 2.938 秒
🦀 Rust + 🕸 Wasm = ❤

wasm-game-of-life/www目录:

1
2
3
4
5
6
7
8
9
├── LICENSE-APACHE
├── LICENSE-MIT
├── README.md
├── bootstrap.js
├── index.html
├── index.js
├── package-lock.json
├── package.json
└── webpack.config.js

www/package.json:

这个package.json预配置了webpackwebpack-dev-server依赖项,以及hello-wasm-pack依赖,这是已发布到 npm 的wasm-pack-template包初始版本。

www/webpack.config.js:
此文件配置 webpack 及其本地开发服务器。
它是预配置的,你根本不需要调整它,就可以使用 webpack 及其本地开发服务器。

www/index.html:
这是网页的根 HTML 文件。它除了加载bootstrap.js之外,没有其他作用,就是装着index.js的薄膜。

www/index.js:
index.js是我们网页的 JavaScript 的主要入口点。
它导入了hello-wasm-packnpm包,包含默认wasm-pack-template的 WebAssembly 编译 和 JavaScript 胶水,然后调用hello-wasm-packgreet函数。

安装依赖项

首先,通过运行在wasm-game-of-life/www子目录内npm install,确保安装本地开发服务器及其依赖项:

1
npm install

此命令只需运行一次,将安装webpackJavaScript 捆绑器 及其开发服务器。

注意webpack,并不是 Rust 和 WebAssembly 的必需品,它只是我们为方便起见,而选择的捆绑器和开发服务器。
Parcel 和 Rollup 也可以支持将 WebAssembly 导入为 ECMAScript 模块.

使用本地wasm-game-of-life

一般我们不是使用来自 npm 的hello-wasm-pack包,而是想使用我们的本地wasm-game-of-life包。
这将使我们能够逐步开发我们的生命游戏程序。

首先,在wasm-game-of-life/pkg目录的里面,运行npm link,以便本地包可以被其他本地包依赖,而不需要将它们发布到 npm:

第二,要使用来自www的,已npm link版本的wasm-game-of-life,通过在wasm-game-of-life/www中运行此命令:

1
npm link wasm-game-of-life

最后,修改wasm-game-of-life/www/index.js,去导入wasm-game-of-life而不是hello-wasm-pack包:

1
2
3
import * as wasm from "wasm-game-of-life";

wasm.greet();

本地展示

终端中,从wasm-game-of-life/www目录内部运行此命令:

1
npm run start

浏览 Web 浏览器http://localhost:8080/你应该收到一条警告(alert)信息:

1
Hello, wasm-game-of-life!

任何时候你做出改变,并希望它们反映在http://localhost:8080/,只是在wasm-game-of-life目录内,重新运行wasm-pack build命令。

例如修改src/lib.rsgreet函数:

1
2
3
4
#[wasm_bindgen]
pub fn greet() {
    alert("Hello, World!");
}

在项目根目录运行wasm-pack build,完成后网页将会alert一个Hello, World!

演示

修改wasm-game-of-life/src/lib.rs中的greet函数,让其拿一个name: &str,这是可以自定义警报消息的参数,并在wasm-game-of-life/www/index.js内部,将您的名称传递给greet函数,让新版本发挥作用。
wasm-pack build,重建.wasm二进制,然后刷新http://localhost:8080/在您的 Web 浏览器中,您应该看到自定义的问候语!

wasm-game-of-life/src/lib.rsgreet:

1
2
3
4
#[wasm_bindgen]
pub fn greet(name: &str) {
    alert(&format!("Hello, {}!", name));
}

wasm-game-of-life/www/index.js中新的greet:

1
2
3
import * as wasm from "wasm-game-of-life";

wasm.greet('nocilantro');

wasm-game-of-life目录下执行wasm-pack build

完成后,刷新网页将会alertHello, nocilantro!

康威生命游戏规则

生命游戏的宇宙是方形单元的无限二维正交网格,每个方格单元处于两种可能状态之一,活着或死亡,或”填充”或”未填充”.
每个细胞与其八个邻居相互作用 - 这八个邻居是水平,垂直或对角相邻的细胞.
在每个步骤中,发生以下转换:

  • 当前细胞为存活状态时,当周围低于 2 个(不包含 2 个)存活细胞时,该细胞变成死亡状态。(模拟生命数量稀少)
  • 当前细胞为存活状态时,当周围有 2 个或 3 个存活细胞时,该细胞保持原样。
  • 当前细胞为存活状态时,当周围有 3 个以上的存活细胞时,该细胞变成死亡状态。(模拟生命数量过多)
  • 当前细胞为死亡状态时,当周围有 3 个存活细胞时,该细胞变成存活状态。(模拟繁殖)

可以把最初的细胞结构定义为种子,当所有在种子中的细胞同时被以上规则处理后, 可以得到第一代细胞图。
按规则继续处理当前的细胞图,可以得到下一代的细胞图,周而复始。

实现康威的生命游戏

无限的宇宙

生命游戏是在无限的宇宙中进行的,但我们没有无限的内存和计算能力。
解决这个相当恼人的限制,通常有以下三种风格:

  • 跟踪宇宙的哪个子集发生了有趣的事情,并根据需要,扩展此区域。在最坏的情况下,这种扩展是无限制的,实现将变得越来越慢,最终耗尽内存。
  • 创建固定大小的 Universe,边缘上的单元格具有较少的邻居 比中间的单元格。这种方法的缺点是无限像滑翔机一样到达宇宙尽头的模式被扼杀了。
  • 创建一个固定大小的周期性 Universe,其中边缘上的单元格具有环绕到 Universe 另一侧的邻居。因为邻居环绕宇宙的边缘,滑翔机可以永远运行。

我们将实现第三种选择。

连接 Rust 和 JavaScript

JavaScript 的垃圾收集堆 - Object,Array和 DOM 节点 是被分配的 - 不同于 WebAssembly 的线性内存空间,我们的 Rust 值 存在于其中。
WebAssembly 目前无法直接访问垃圾收集堆 (截至 2018 年 4 月,预计会随“主机绑定 host-bindings”提案而改变) 。
另一方面,JavaScript 可以读取和写入 WebAssembly 线性存储空间,但仅作为一个ArrayBuffer标量值 (u8,i32,f64等等...) WebAssembly 函数也接受,并返回标量值.
这些是构成 WebAssembly 和 JavaScript 通信 的所有构建块。

wasm_bindgen定义了,如何与跨边界复合结构一起工作的共识。
它涉及封装 Rust 结构,将指针包装在 JavaScript 类 中以实现可用性,或者 从 Rust 索引到 一个 JavaScript 对象表格。
wasm_bindgen非常方便,但它不需要考虑我们的数据表示,以及跨越这个边界传递什么值和结构.
相反,将其视为实现您选择的接口设计的工具。

在设计 WebAssembly 和 JavaScript 之间的接口时,我们希望针对以下属性进行优化:

  • 最小化 WebAssembly 线性存储器的 进/出复制: 不必要的副本会产生不必要的开销。
  • 最小化序列化和反序列化: 与复制类似,序列化和反序列化也会产生开销,并且通常也会进行复制。如果我们可以将不透明的控制,传递给数据结构 - 而不是在一边序列化后,将其复制到 WebAssembly 线性存储器中的某个已知位置,并在另一边进行反序列化 - 我们通常可以减少大量开销。wasm_bindgen帮助我们 定义和使用 JavaScript 的Object或 已封装的 Rust 结构的不透明控制。

作为一般的经验法则,一个好的 JavaScript↔WebAssembly 接口设计,通常是将大型,长寿命的数据结构实现,为生活在 WebAssembly 线性内存 中的 Rust 类型,并作为不透明控制暴露给 JavaScript.
JavaScript 调用,这些持有不透明控制的导出了的 WebAssembly 函数,转换数据,执行繁重的计算,查询数据,最终返回一个小的可复制结果。
通过仅返回计算的小结果,我们避免在 JavaScript 垃圾收集堆和 WebAssembly 线性存储器 之间,来回复制和序列化所有内容。

在我们的生命游戏中连接 Rust 和 JavaScript

让我们首先列举一些要避免的危险。 我们不希望在每次tick,都将整个 Universe 复制到 WebAssembly 线性内存。
我们不希望为宇宙中的每个单元分配对象,也不想强加一个跨边界调用来读写每个单元。

这给我们留下了什么?
我们可以将 Universe 表示为,位于 WebAssembly 线性内存中的平面数组,并且每个单元格都有一个字节。
0是一个死单元格,1是一个活单元格。

要在 Universe 的给定行和列中,查找单元格的数组索引,我们可以使用以下公式:

1
index(row, column, universe) = row * width(universe) + column

我们有几种方法可以将 Universe 的单元格暴露给 JavaScript。
首先,我们为Universe添加std::fmt::Display实现,可以用来产生 一个单元格的 RustString ,渲染为文本字符。
然后将此 Rust String 从 WebAssembly 线性内存 复制到 JavaScript 的垃圾回收堆中 的 JavaScript String 中,然后通过设置HTML的textContent显示。
在后面,我们将推演这个实现,以避免在堆之间复制 Universe 的单元格,和渲染到<canvas>

另一个可行的设计替代方案是, Rust 返回每次 tick 后,更改状态的每个单元格的列表,而不是将整个 Universe 暴露给 JavaScript。
好处在于,JavaScript 在渲染时不需要遍历整个 Universe ,只需要相关的子集。
问题权衡,在于这种 基于delta的设计实现起来,稍微困难一些。

Rust 实现

让我们开始删除 wasm-game-of-life/src/lib.rsalert导入 和greet 函数,并用单元格的类型定义替换它们:

1
2
3
4
5
6
7
#[wasm_bindgen]
#[repr(u8)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Cell {
    Dead = 0,
    Alive = 1,
}

重要的是我们拥有#[repr(u8)],以便每个单元格表示为单个字节。
同样重要的是Dead代表0,那个Alive1,这样我们就可以轻松地计算一个单元格的活邻居。

接下来,让我们定义宇宙(Universe)。
宇宙具有宽度和高度,以及长度为width * height的单元格向量。

要访问 给定行和列 的单元格,我们将 行和列 转换为 单元格向量 的索引,如前所述:

1
2
3
4
5
impl Universe {
    fn get_index(&self, row: u32, column: u32) -> usize {
        (row * self.width + column) as usize
    }
}

为了计算单元格的下一个状态,我们需要计算 其邻居有多少 是活着的。 我们来写一个live_neighbor_count方法,做到这一点!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
impl Universe {
    //...

    fn live_neighbor_count(&self, row: u32, column: u32) -> u8 {
        let mut count = 0;
        for delta_row in [self.height - 1, 0, 1].iter().cloned() {
            for delta_col in [self.width - 1, 0, 1].iter().cloned() {
                if delta_row == 0 && delta_col == 0 { continue; }
                let neighbor_row = (row + delta_row) % self.height;
                let neighbor_col = (column + delta_col) % self.width;
                let idx = self.get_index(neighbor_row, neighbor_col);
                count += self.cells[idx] as u8;
            }
        }
        count
    }
}

live_neighbor_count方法使用 deltasmodulo 来避免宇宙的边缘情况。
当应用-1的增量时,我们添加 self.height - 1,然后让 modulo 做它的事,而不是试图减去1
rowcolumn可以为0,如果我们试图减去1,从他们来看,会有一个 无符号整数 下溢。

现在我们拥有了当前计算下一代所需的一切!
每个游戏的规则遵循 转换条件用match直接表达。
另外,因为我们希望 JavaScript 控制 tick 时间,我们将把这个方法放在一个#[wasm_bindgen]注释下,以便它暴露给 JavaScript。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 公有方法,暴露给 JavaScript
#[wasm_bindgen]
impl Universe {
    pub fn tick(&mut self) {
        let mut next = self.cells.clone();

        for row in 0..self.height {
            for col in 0..self.width {
                let idx = self.get_index(row, col);
                let cell = self.cells[idx];
                let live_neighbors = self.live_neighbor_count(row, col);

                let next_cell = match (cell, live_neighbors) {
                    // 规则1: 任何少于两个邻居的活细胞死亡,就好像是由于人口不足造成的一样。。
                    (Cell::Alive, x) if x < 2 => Cell::Dead,
                    // 规则2: 任何一个有两个或三个的活体细胞都能传到下一代
                    (Cell::Alive, 2) | (Cell::Alive, 3) => Cell::Alive,
                    // 规则3: 任何居住着三个以上邻居的活细胞都会死亡,就好像是由于人口过剩
                    (Cell::Alive, x) if x > 3 => Cell::Dead,
                    // 规则4: 任何一个只有三个相邻的活细胞的死细胞都会变成活细胞,就像通过繁殖一样
                    (Cell::Dead, 3) => Cell::Alive,
                    // 所有其他单元格保持相同状态
                    (otherwise, _) => otherwise,
                };

                next[idx] = next_cell;
            }
        }

        self.cells = next;
    }
}

到目前为止,宇宙的状态被表示为 单元格 的载体。
为了使这个可读,让我们实现一个基本的文本渲染器。
我们的想法是逐行将 Universe 写成文本,对于每个活着的单元格,打印 Unicode 字符◼️ (”黑色方格”)。对于死单元格,我们将打印◻️ (”白色方格”) 。

通过实现来自 Rust 标准库 的Displaytrait ,我们可以添加一种面向用户, 格式化结构的方法。
这也会自动给我们一个to_string方法.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
use std::fmt;

impl fmt::Display for Universe {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        for line in self.cells.as_slice().chunks(self.width as usize) {
            for &cell in line {
                let symbol = if cell == Cell::Dead {
                    "◻️"
                } else {
                    "◼️"
                };
                write!(f, "{}", symbol)?;
            }
            write!(f, "\n")?;
        }
        Ok(())
    }
}

最后,我们定义一个构造函数,用一个有趣的 活单元格和死单元格 模式来初始化宇宙,以及render方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#[wasm_bindgen]
impl Universe {
    // ...

    pub fn new() -> Universe {
        let width = 64;
        let height = 64;

        let cells = (0..width * height)
            .map(|i| {
                if i % 2 == 0 || i % 7 == 0 {
                    Cell::Alive
                } else {
                    Cell::Dead
                }
            })
            .collect();

        Universe {
            width,
            height,
            cells,
        }
    }

    pub fn render(&self) -> String {
        self.to_string()
    }
}

有了这个,我们的生命游戏 Rust 实现的一半就完成了!

wasm-game-of-life目录内,通过运行wasm-pack build重新编译为 WebAssembly。

使用 JavaScript 渲染

首先,让我们添加一个用于渲染宇宙的<pre>元素到wasm-game-of-life/www/index.html,就放在 <script> 上好了:

1
2
3
4
<body>
  <pre id="game-of-life-canvas"></pre>
  <script src="./bootstrap.js"></script>
</body>

另外,我们想要这个<pre>,以网页中间为中心.
我们可以使用 CSS flex 来完成这项任务。
添加以下内容<style>,到index.html<head>里面:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<style>
  body {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
  }
</style>

wasm-game-of-life/www/index.js的顶端,让我们修复我们的导入来引入Universe,而不是旧的greet函数:

1
import {Universe} from "wasm-game-of-life";

另外,获取我们刚加的<pre>元素,并实例化新 Universe 的元素:

1
2
const pre = document.getElementById('game-of-life-canvas');
const universe = Universe.new();

JavaScript 运行在[一个requestAnimationFrame循环][requestanimationframe]。
在每次迭代中,它将当前的 Universe 绘制到<pre>,然后运行Universe::tick

1
2
3
4
5
6
const renderLoop = () => {
  pre.textContent = universe.render();
  universe.tick();

  requestAnimationFrame(renderLoop);
};

要开始渲染过程,我们所要做的就是为渲染循环的第一次迭代进行初始调用:

1
requestAnimationFrame(renderLoop);

确保你的开发服务器还在运行 (wasm-game-of-life/www目录中,执行 npm run start ) 和这就是http://localhost:8080/ 现在的样子:

直接从内存渲染到 Canvas

在 Rust 中生成 (和分配) 一个String, 然后有wasm-bindgen将其转换为有效的 JavaScript 字符串 ,来会生成 Universe 单元格 的不必要副本。
其实在 JavaScript 代码 知道 Universe 的宽度和高度,并且可以直接从 JavaScript 中读取 WebAssembly 线性内存 中的单元格字节, 我们就可以修改render方法,用来返回 单元数组的开头指针。

还有,我们将切换到使用Canvas API。 而不是渲染 unicode 文本

首先,让我们把pre,换成了一个<canvas> (它也应该在<body><script>加载我们的 JavaScript 之前) :

wasm-game-of-life/www/index.html内,让我们把之前添加的<pre>换成准备渲染的一个<canvas>(它也应该在<body>, 在<script>加载我们的 JavaScript 之前):

1
2
3
4
<body>
  <canvas id="game-of-life-canvas"></canvas>
  <script src="./bootstrap.js"></script>
</body>

为了从 Rust 实现 中获取必要的信息,我们需要为 Universe 的宽度,高度和指向 其单元数组 的指针 添加更多的一些 getter函数。
所有这些都暴露在 JavaScript 中。添加这些内容wasm-game-of-life/src/lib.rs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
/// Public methods, exported to JavaScript.
#[wasm_bindgen]
impl Universe {
    // ...
    pub fn width(&self) -> u32 {
        self.width
    }

    pub fn height(&self) -> u32 {
        self.height
    }

    pub fn cells(&self) -> *const Cell {
        self.cells.as_ptr()
    }
}

接下来,在wasm-game-of-life/www/index.js,我们也从wasm-game-of-life导入Cell,和让我们定义 JavaScript 在渲染 Canvas 时将使用的一些常量:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import {Universe, Cell} from 'wasm-game-of-life';

const CELL_SIZE = 5; // px
const GRID_COLOR = '#CCCCCC';
const DEAD_COLOR = '#FFFFFF';
const ALIVE_COLOR = '#000000';

// These must match `Cell::Alive` and `Cell::Dead` in `src/lib.rs`.
const DEAD = 0;
const ALIVE = 1;

现在,让我们重写当前的 JS 代码 (导入除外) ,不再写入<pre>的textContent,而是专注在<canvas>:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// 构造 the universe, and get its width and height.
const universe = Universe.new();
const width = universe.width();
const height = universe.height();

// Give the canvas room for all of our cells and a 1px border
// around each of them.
const canvas = document.getElementById('game-of-life-canvas');
canvas.height = (CELL_SIZE + 1) * height + 1;
canvas.width = (CELL_SIZE + 1) * width + 1;

const ctx = canvas.getContext('2d');

const renderLoop = () => {
  universe.tick();

  drawGrid();
  drawCells();

  requestAnimationFrame(renderLoop);
};

为了在单元格之间绘制网格,我们绘制 一组等间隔 的 水平线 和 一组等间距 的 垂直线。
这些线 纵横交错 形成网格。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
const drawGrid = () => {
  ctx.beginPath();
  ctx.lineWidth = 1 / window.devicePixelRatio;
  ctx.strokeStyle = GRID_COLOR;

  // Vertical lines.
  for (let i = 0; i <= width; i++) {
    ctx.moveTo(i * (CELL_SIZE + 1) + 1, 0);
    ctx.lineTo(i * (CELL_SIZE + 1) + 1, (CELL_SIZE + 1) * height + 1);
  }

  // Horizontal lines.
  for (let j = 0; j <= height; j++) {
    ctx.moveTo(0, j * (CELL_SIZE + 1) + 1);
    ctx.lineTo((CELL_SIZE + 1) * width + 1, j * (CELL_SIZE + 1) + 1);
  }

  ctx.stroke();
};

我们可以直接通过memory拿到 WebAssembly 的 线性内存, 而这个memory由原生 wasm 模块wasm_game_of_life_bg提供。
为了绘制 单元格,我们拿到 universe's cells 的指针,构造一个覆盖单元格缓存的Uint8Array,迭代每个单元格,并分别根据 单元格是死还是活,绘制白色或黑色矩形。
通过使用 指针 和 覆盖,我们避免在每次tick上跨边界复制单元格。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 文件顶部,导入 WebAssembly memory
import { memory } from "wasm-game-of-life/wasm_game_of_life_bg";

// ...

const getIndex = (row, column) => {
  return row * width + column;
};

const drawCells = () => {
  const cellsPtr = universe.cells(); // < universe's cells
  const cells = new Uint8Array(memory.buffer, cellsPtr, width * height);

  ctx.beginPath();

  for (let row = 0; row < height; row++) {
    for (let col = 0; col < width; col++) {
      const idx = getIndex(row, col);

      ctx.fillStyle = cells[idx] === DEAD ? DEAD_COLOR : ALIVE_COLOR;

      ctx.fillRect(
        col * (CELL_SIZE + 1) + 1,
        row * (CELL_SIZE + 1) + 1,
        CELL_SIZE,
        CELL_SIZE
      );
    }
  }

  ctx.stroke();
};

要开始渲染过程,我们将使用与 上部分相同的代码 ,来开始渲染循环的第一次迭代:

1
requestAnimationFrame(renderLoop);

请注意,在执行requestAnimationFrame()之前,我们要调用drawGrid()drawCells()
我们这样做的原因是,在我们进行修改之前,绘制宇宙的初始状态。如果我们改为简单地调用requestAnimationFrame(renderLoop),我们最终得到的第一帧实际的绘制情况是,在第一次调用universe.tick()后,也就是这些单元格生命状态的第二次“tick”

1
2
3
drawGrid();
drawCells();
requestAnimationFrame(renderLoop);

通过在根wasm-game-of-life目录中运行此命令,来重构建 WebAssembly 和绑定粘合剂:

1
wasm-pack build

确保您的开发服务器仍在运行。若没有,请从wasm-game-of-life/www目录内部再次启动:

1
npm run start

如果你刷新http://localhost:8080/,你应该受到令人兴奋的展示!

您可以 checkout chapter-one 分支 找到完整代码.

还有一个非常巧妙的算法,来实现生命游戏,叫做hashlife
它使用积极的内存,实际上计算后代的时间越长,获得的指数级更快!
强烈建议您自己去了解hashlife!

测试 Conway’s 生命游戏

现在我们在浏览器中使用 JavaScript 运用 Rust(编译出 wasm) 实现了生命游戏渲染,接下来让我们来谈谈测试 Rust 生成的 WebAssembly 函数。

我们要测试一下tick函数,以确保它为我们提供预期的输出。

下一步,我们将要在wasm_game_of_life/src/lib.rs文件的impl Universe代码区块内部,创建一些 settergetter 函数。我们准备创建一个set_width和 一个set_height函数,所以我们可以创建不同大小的Universe(宇宙)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
#[wasm_bindgen]
impl Universe {
    // ...

    /// 设置 宇宙 的 宽度.
    ///
    /// 将所有的单元,重新设为 死亡 状态
    pub fn set_width(&mut self, width: u32) {
        self.width = width;
        self.cells = (0..width * self.height).map(|_i| Cell::Dead).collect();
    }

    /// 设置 宇宙 的 高度.
    ///
    /// 将所有的单元,重新设为 死亡 状态
    pub fn set_height(&mut self, height: u32) {
        self.height = height;
        self.cells = (0..self.width * height).map(|_i| Cell::Dead).collect();
    }

}

我们打算创造另一个,一样是wasm_game_of_life/src/lib.rs文件中impl Universe代码区块内部,但没有#[wasm_bindgen]属性。
我们需要一些测试所需的函数,但不希望这些函数给到我们的 JavaScript。Rust 生成的 WebAssembly 函数无法返回 借用的 引用。

我们将编写实现get_cells,主要用来得到一个Universe中的cells的内容。
我们还会写一个set_cells函数,这样我们才可以设置一个Universe中,特定行列的cellsAlive.(活的)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
impl Universe {
    /// 给出 全部宇宙 死和活的值
    pub fn get_cells(&self) -> &[Cell] {
        &self.cells
    }

    /// 通过传递 作为数组的 单元(行与列),可在一个宇宙内设置该单元为活的
    pub fn set_cells(&mut self, cells: &[(u32, u32)]) {
        for (row, col) in cells.iter().cloned() {
            let idx = self.get_index(row, col);
            self.cells[idx] = Cell::Alive;
        }
    }

}

现在我们要在wasm_game_of_life/tests/web.rs文件中创建我们的测试。

在我们这样做之前,文件中已经有一个工作测试。
您可以通过在wasm-game-of-life目录里面,运行wasm-pack test --chrome --headless确认 Rust 生成的 WebAssembly 测试是否正常工作。
你也可以使用--firefox,--safari,和--node在这些浏览器中测试代码的选项.

wasm_game_of_life/tests/web.rs文件里面,我们需要导出我们的wasm_game_of_life箱子和Universe类型.

1
2
extern crate wasm_game_of_life;
use wasm_game_of_life::Universe;

wasm_game_of_life/tests/web.rs文件里面,我们要创建一些太空船(spaceship)构建器函数。

先要一个 input_spaceship(输入的宇宙飞船) ,这样我们会让tick函数开启调用,还有我们在一次 tick 后,要获取 expected_spaceship(预期的宇宙飞船) 。
我们选择了想要初始化为Alive的单元格,在input_spaceship函数中创造我们的宇宙飞船。
在手动的input_spaceship过了一次 tick 后, 宇宙飞船的位置就在expected_spaceship函数。
您可以自己确认下,在一次 tick 后,输入飞船的单元与预期的相同.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#[cfg(test)]
pub fn input_spaceship() -> Universe {
    let mut universe = Universe::new();
    universe.set_width(6);
    universe.set_height(6);
    universe.set_cells(&[(1,2), (2,3), (3,1), (3,2), (3,3)]);
    universe
}

#[cfg(test)]
pub fn expected_spaceship() -> Universe {
    let mut universe = Universe::new();
    universe.set_width(6);
    universe.set_height(6);
    universe.set_cells(&[(2,1), (2,3), (3,2), (3,3), (4,2)]);
    universe
}

现在我们实现test_tick函数。
首先,我们创建input_spaceship()expected_spaceship(),各一个实例。
然后,我们在input_universe上调用tick
最后,我们使用了assert_eq!宏来调用get_cells(),确保input_universeexpected_universe有同样的Cell数组值。
我们加上了#[wasm_bindgen_test]属性到我们的代码块,所以我们可以测试 Rust 生成的 WebAssembly 代码,并使用wasm-build test测试 WebAssembly 代码。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#[wasm_bindgen_test]
pub fn test_tick() {
    // 让我们创建一个小点 的宇宙,带着我们微飞船测试吧!
    let mut input_universe = input_spaceship();

    // 我们宇宙的一次滴答后,我们的飞船应该变成了这样
    let expected_universe = expected_spaceship();

    // 调用 `tick` ,然后看看 `Universe`的单元是否一致
    input_universe.tick();
    assert_eq!(&input_universe.get_cells(), &expected_universe.get_cells());
}

wasm-game-of-life目录中,通过运行wasm-pack test --firefox --headless运行测试。

调试

为恐慌(panic),启用日志记录

如果我们的代码发生恐慌,我们希望在开发者控制台中,显示信息性错误消息。

我们的wasm-pack-template附带一个console_error_panic_hook箱,其有个可选的,默认启用的依赖项,你可在wasm-game-of-life/src/utils.rs看到已配置了。
我们需要做的就是在初始化函数或常用代码路径中安装钩子。
我们可以在wasm-game-of-life/src/lib.rs中的,Universe::new构造函数里面调用它:

1
2
3
4
5
pub fn new() -> Universe {
    utils::set_panic_hook();

    // ...
}

记录功能,添加到我们的生命游戏中

运用console.log函数的方式,是由web-sys添加一些记录日志,记录我们Universe::tick函数的每个细胞。

首先,在wasm-game-of-life/Cargo.toml添加web-sys依赖项,并启用它的"console"功能(特性):

1
2
3
4
5
[dependencies.web-sys]
version = "0.3"
features = [
  "console",
]

为了更符合偷懒,我们将包装console.log到类println!形式的宏中:

1
2
3
4
5
6
7
8
extern crate web_sys;

// 一个 macro(宏) 提供 `println!(..)`-形式 语法,给到 `console.log` 日志功能.
macro_rules! log {
    ( $( $t:tt )* ) => {
        web_sys::console::log_1(&format!( $( $t )* ).into());
    }
}

在每个 Tick 之间,能用调试器暂停下

浏览器的步进调试器,对检查 Rust 生成的 WebAssembly 与 JavaScript 的交互 非常有用。

例如,我们可用调试器,在我们的renderLoop函数每次迭代时,暂停。
只需要放置一个 JavaScript 的 debugger;声明,位于我们的universe.tick()之上。

1
2
3
4
5
6
7
8
9
const renderLoop = () => {
  debugger;
  universe.tick();

  drawGrid();
  drawCells();

  requestAnimationFrame(renderLoop);
};

这为我们提供了一个方便的检查点,用于检查记录的消息,并将当前渲染的’帧’与前一’帧’进行比较。

增加交互性

我们将通过在 Game of Life 实现中, 添加一些交互功能来继续探索 JavaScript 和 WebAssembly 接口。
我们想让用户通过单击,来切换单元格是活的还是死亡,并允许暂停游戏,这使得绘制单元格模式更加容易。

暂停和恢复游戏

让我们添加一个按钮,来切换游戏是正在播放还是暂停。
index.html<canvas>上方添加按钮:

在 JavaScript 中,我们将进行以下更改:

  • 跟踪最新调用requestAnimationFrame返回的标识符, 以便我们可以以此,调用cancelAnimationFrame来取消那个标识符动画。
  • 单击播放/暂停按钮时,检查我们是否具有排队动画帧的标识符。1.点击时,游戏当前正在播放,取消动画帧renderLoop,有效地暂停游戏. 2.点击时,当前暂停,若没有排队动画帧的标识符,我们想运行requestAnimationFrame恢复比赛。

因为 JavaScript 正在驱动 Rust 和 WebAssembly,这就是我们需要做的所有,不过我们不需要更改 Rust 源代码。

我们介绍一下animationId变量来跟踪requestAnimationFrame返回的标识符。
当没有排队的动画帧时,我们将此变量设置为null。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
let animationId = null;

// 这个函数与之前的一样, 除了把`requestAnimationFrame`的结果
// 分配到 `animationId`.
const renderLoop = () => {
  universe.tick();

  drawCells();
  drawGrid();

  animationId = requestAnimationFrame(renderLoop);
};

在任何时刻,我们都可以通过animationId检查游戏,来判断游戏是否暂停:

1
2
3
const isPaused = () => {
  return animationId === null;
};

现在,当点击 播放/暂停 按钮时,我们会检查游戏当前是暂停还是正在播放,要么继续播放renderLoop动画,要么取消下一个动画帧。
此外,我们更新按钮的文本图标,以反映按钮在下次单击时将执行的操作。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
const playPauseButton = document.getElementById('play-pause');

const play = () => {
  playPauseButton.textContent = '⏸';
  renderLoop();
};

const pause = () => {
  playPauseButton.textContent = '▶';
  cancelAnimationFrame(animationId);
  animationId = null;
};

playPauseButton.addEventListener('click', event => {
  if (isPaused()) {
    play();
  } else {
    pause();
  }
});

最后,我们直接调用requestAnimationFrame(renderLoop)用来启动之前的游戏及其动画, 但我们想用play替换它,以便按钮获得正确的初始文本图标。

1
2
// This used to be `requestAnimationFrame(renderLoop)`.
play();

刷新http://localhost:8080/,现在你应该可以通过点击按钮来暂停和恢复游戏!

切换一个 Cell 的状态"click"活动

现在我们可以暂停游戏了,现在是时候添加通过点击它们来改变细胞的能力了。

切换单元格是将其状态从活动状态转换为死亡状态,或从死亡状态转换为活动状态:

1
2
3
4
5
6
7
8
impl Cell {
    fn toggle(&mut self) {
        *self = match *self {
            Cell::Dead => Cell::Alive,
            Cell::Alive => Cell::Dead,
        };
    }
}

要切换给定行和列的单元格状态,我们将行和列对转换为单元格向量的索引,并在该索引处的单元格上调用toggle方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
/// 公有方法 methods, 导出到 JavaScript.
#[wasm_bindgen]
impl Universe {
    // ...

    pub fn toggle_cell(&mut self, row: u32, column: u32) {
        let idx = self.get_index(row, column);
        self.cells[idx].toggle();
    }
}

这个方法是在impl带有#[wasm_bindgen]注释的区块内,这样它就可以被 JavaScript 调用。

在 JavaScript 中,我们会监听 点击事件<canvas>元素,将 click事件的页面 相对坐标转换为画布相对坐标, 然后转换为行和列,调用toggle_cell方法,最后重绘场景。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
canvas.addEventListener('click', event => {
  const boundingRect = canvas.getBoundingClientRect();

  const scaleX = canvas.width / boundingRect.width;
  const scaleY = canvas.height / boundingRect.height;

  const canvasLeft = (event.clientX - boundingRect.left) * scaleX;
  const canvasTop = (event.clientY - boundingRect.top) * scaleY;

  const row = Math.min(Math.floor(canvasTop / (CELL_SIZE + 1)), height - 1);
  const col = Math.min(Math.floor(canvasLeft / (CELL_SIZE + 1)), width - 1);

  universe.toggle_cell(row, col);

  drawCells();
  drawGrid();
});

再次刷新http://localhost:8080/,您现在可以通过单击单元格,并切换其状态来绘制自己的模式.

您可以在 checkout chapter-two 分支 , 找到此实现的完整源代码.

时间分析

我们将改进 Game of Life 实现的性能。我们将使用时间分析来指导我们的工作。

继续之前,自己熟悉下时间分析 Rust 和 WebAssembly 代码的可用工具。