使用 rust 和 webassembly 开发 game of life

这是一篇翻译,原文,这可能是第一篇系统讲解 rustwasm 的文章了。

这本书适合谁?

这本书适合任何对快速编译 Rust 和 Webassembly 感兴趣的人,相关的代码已经发布在网上。你应该已经了解一些 Rust 的知识,对 JavaScript HTML 和 css 很熟悉,但你不需要是在这些方面的专家。

还不了解 rust?请先参阅开始使用 rust 语言。 不了解 JavaScript 的 html 或者是 css?请参阅MDN

为什么用 rust 和 webAssembly

底层支持和高效(Low-Level Control with Hign-Level Ergonomics)

Javascript 的应用,纠结于如何保持高效运作。但是 JavaScript 的动态类型系统和垃圾回收机制,使他们不能高效。看起来很小的修改,如果不小心走出了 JIT 的舒适区,看起来很小的修改都会导致很严重的错误。

.wasm 文件大小

因为要通过网络下载,代码的大小就变得异常重要。Rust 不需要运行环境,使得编译文件不需要包括垃圾回收器。这些文件包括的只有真正需要的函数。

不要重写所有的东西

现有的代码不需要被扔走,你可以把性能最严重的 JavaScript 函数,交给 rust 去执行。

和其他工具交互融洽

Rust 和 WebAssembly 支持现有的工具链,它支持 ecmascript 模块,并且你依然可以使用现有的工具链如 NPM,webpack 和 greenkeeper。

背景和相关概念

什么是 WebAssembly

WebAssembly(wasm)是一个简单的机器模块拥有大量的定义。它被设计得以相近于原生的速度便携紧密地执行。

作为一个开发语言,尽管是以两种方式展示的格式,wasm 依然表示于同样的结构。

.wat书写的斐波那契数列如下:

(module
  (func $fac (param f64) (result f64)
    get_local 0
    f64.const 1
    f64.lt
    if (result f64)
      f64.const 1
    else
      get_local 0
      get_local 0
      f64.const 1
      f64.sub
      call $fac
      f64.mul
    end)
  (export "fac" (func $fac)))

如果感兴趣的话,可以使用此工具执行上面的代码。

线性内存

Wasm 使用的内存模式很简单。一个 wasm 模块,可以访问的一系列内存,被限制于一个字节数组中。这些内存会增长为多个页(64K)不会收缩。

Wasm 是仅仅为 web 开发的吗?

尽管在 JavaScript 和 web 社区中有很多讨论。WASM 并没有考虑过它的运用环境。所以目前只能定义它为将来可以使用的便携运行格式。但就目前而言,wasm 仍然在很多方面与 JavaScript 有关。不仅仅是浏览器,还有 Node.js。

关于本书

这一部分开始使用 Rust 和 WebAssembly 开发Conway 的 Game of Life

本章会讲到以下内容。

安装工具

本节将会介绍编译 Rust 编译 WASM 并和 JavaScript 集成的工具链。

Rust 工具链

你需要安装 rust 的标准工具链,rustup,rustc 和 cargo(强烈建议你们在 WSl 的环境下面工作)。

WASM 已经推动 Rust 新特性进入稳定版,所以我们需要有 1.30 或更新版本。

wasm-pack

wasm-pack是一站式的建造测试以及发布 rust 相关的 wasm 应用工具。

cargo install wasm-pack

cargo-generate

cargo-generate帮助你使用现存的 Git 仓库作为模板新建 Rust 项目。

cargo install cargo-generate

NPM

npm是 JavaScript 的包装管理器。我们将利用它,去安装和运行 JavaScript 的打包和测试部署。我们将把我们编译好的.wasm文件放到 npm 的包中。

如果你已经安装了 NPM 可以执行以下命令,安装最新版。

npm install npm@latest -g

你好,世界

通过本部分可以创建一个 Rust+WASM 页面,并能在页面弹窗展示"Hello, World!"

复制项目模板

这个项目的模板已经提前编译好,可以借此快速绑定、集成和打包成 Web 项目。

利用模板创建项目的命令:

cargo generate --git https://github.com/rustwasm/wasm-pack-template

它会提醒你新建一个项目名称,这里我们先使用”wasm-game-of-life”。

文件结构

进入项目文件夹。

cd wasm-game-of-life

以下是项目文件夹:

wasm-game-of-life/
├── Cargo.toml
├── LICENSE_APACHE
├── LICENSE_MIT
├── README.md
└── src
    ├── lib.rs
    └── utils.rs

接下来详细看一下:

wasm-game-of-life/Cargo.toml

Cargo.toml文件描述cargo的依赖和源文件,Rust 的包管理工具和编译工具。这个包括wasm-bindgen依赖,我们会稍后了解其他的依赖,还有一些用来初始化.wasmcrate-type库。

wasm-game-of-life/src/lib.rs

src/lib文件放在 Rust 项目的更目录下面。它使用wasm-bindgen去和 JavaScript 链接。它能引入window.alert这个 JavaScript 函数,并暴露greet函数,并弹出弹框。

mod utils;
use wasm_bindgen::prelude::*;

// 当wee_alloc特性被打开,将会使用wee_alloc作为全局分匹配器
#[cfg(feature = "wee_alloc")]
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;

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

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

wasm-game-of-life/src/utils.rs

src/utils模块为编译 Rust 到 WASM 提供工具函数,我们后面会在调试时提到它,现在先忽略。

编译项目

使用wasm-pack依赖以下工具:

为了完成以上内容,需要在根目录执行以下命令:

wasm-pack build

编译完成后,我们可以看到pkg里面的结构,里面应该有如下文件。

pkg/
├── package.json
├── README.md
├── wasm_game_of_life_bg.wasm
├── wasm_game_of_life.d.ts
└── wasm_game_of_life.js

README.md文件是直接从根目录复制的,但是其他文件完全是新生成的。

wasm-game-of-life/pkg/wasm_game_of_life_bg.wasm

.wasm文件是 Rust 工具链使用 Rust 源代码生成的 WASM 的二进制文件,它包括全部的函数和数据,比方说,爆露出来的greet函数。

wasm-game-of-life/pkg/wasm_game_of_life.js

这个.js文件是wasm-bindgen引入 DOM 和 JavaScript 方法到 Rust 中,并油耗地暴露 WASM 的 API 到 JavaScript 中。举个例子,这里个greet函数包裹了 WASM 中的greet函数,目前,这个粘合还没做任何功能,当我们逐渐从 WASM 和 JavaScript 中传输数据,他会提供帮助。

import * as wasm from "./wasm_game_of_life_bg";

export function greet() {
  return wasm.greet();
}

wasm-game-of-life/pkg/wasm_game_of_life.d.ts

这个.d.ts是 TypeScript 链接 JavaScript 的文件。如果你的项目中使用了 TypeScript,你可以让你的 WebAssembly 项目被类型检查,并且你的 IDE 会提供代码提醒和自动完成功能。

export function greet(): void;

wasm-game-of-life/pkg/package.json

这个文件包括了所有生成的文件描述,并使得这个项目能够作为一个使用 WebAssembly 的 NPM 包,能够集成到 JavaScript 工具链并发布至 NPM。

{
  "name": "wasm-game-of-life",
  "collaborators": ["Your Name <[email protected]>"],
  "description": null,
  "version": "0.1.0",
  "license": null,
  "repository": null,
  "files": ["wasm_game_of_life_bg.wasm", "wasm_game_of_life.d.ts"],
  "main": "wasm_game_of_life.js",
  "types": "wasm_game_of_life.d.ts"
}

开始加入页面

想要wasm-game-of-life能够展示到页面中,需要使用create-wasm-app JavaScript 模板

在项目根目录执行以下命令:

npm init wasm-app www

这是wasm-game-of-life/www文件夹包括的文件。

wasm-game-of-life/www/
├── bootstrap.js
├── index.html
├── index.js
├── LICENSE-APACHE
├── LICENSE-MIT
├── package.json
├── README.md
└── webpack.config.js

wasm-game-of-life/www/package.json

这个文件包括已经配置好的webpackwebpack-dev-server依赖,和hello-wasm-pack,版本号为已经发布到 NPM 上面的版本号。

wasm-game-of-life/www/webpack.conf.js

这个是用来配置 webpack 和开发服务器的文件。该文件已经提前布置好,如果只是开发则无需过多关心这个文件。

wasm-game-of-life/www/index.html

这是页面的 HTML 文件,它是来调用bootstrap.js的。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Hello wasm-pack!</title>
  </head>
  <body>
    <script src="./bootstrap.js"></script>
  </body>
</html>

wasm-game-of-life/www/index.js

这是 JavaScript 的入口文件,他引入了hello-wasm-pack,并带哦用了 greet 函数。

import * as wasm from "hello-wasm-pack";

wasm.greet();

安装 NPM 依赖

首先保证已经在www文件夹下面执行过npm i,这个命令会安装好现有依赖包括 webpack 和开发服务器。

注意 webpack 并不是必须的,他只是个打包器并提供了开发服务器,这是我们选择它的原因。Parcel 和 Rollup 一样支持 WebAssembly 模块。你也可以选择不使用打包器

在 www 文件夹中使用本地 wasm-game-of-life 包

相比于使用 NPM 线上的hello-wasm-pack,使用本地文件会提高我们的开发舒适度。

打开www/package.json,找到devDependencies,在兄弟节点增加dependencies字段,并在里面增加"wasm-game-of-life": "file:../pkg"

{
  // ...
  "dependencies": {                     // Add this three lines block!
    "wasm-game-of-life": "file:../pkg"
  },
  "devDependencies": {
    //...
  }
}

接下来修改www/index.js引入 greet 函数。

import * as wasm from "wasm-game-of-life";

wasm.greet();

既然修改了 package.json,则需要重新安装他。

npm install

好了,现在服务器可以成功运行了。

启动本地服务

接下来,打开一个新终端来在后台运行服务器,请在www文件夹下执行如下命令。

npm run start

打开http://localhost:8080,应当会弹出如下弹窗。

弹窗

练习

修改 greet 函数,引入参数name: &str,重新执行wasm-pack build,并刷新页面使得弹窗中能够显示”Hello, {name}“。

答案,不许看!

修改src/lib.rs

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

再修改 JavaScript 绑定www/index.js

wasm.greet("Your name");

Conway 的生命游戏的游戏规则

如果你已经了解Conway 的生命游戏,可以跳过这部分。

整个 Conway 的生命游戏是在一个无限的二维的正交格子宇宙中,每一个细胞拥有两种生命状态,生或者死。或者说可增殖或者不可增殖。每一个细胞都和它的 8 个邻居交互,它们分别是纵向的,斜向的,横向的相邻。并且每一步都会发生如下的变化。

  1. 任何一个活着的细胞,如果有少于两个邻居就会死亡。
  2. 任何一个活细胞拥有两个或三个活着的邻居,则会继续增殖。
  3. 任何一个活着的细胞拥有三个以上活着的的邻居,则会死亡。
  4. 任何一个死掉的细胞,如果有三个活着的邻居,则会重生。

最初的图案组成了最初的世界。第 1 代是按照以上的规则生成的,每一个细胞的生成和死亡都是同时的。他们的生存和死亡这一个时间我们称之为一刻。用程序的语言来说,这一刻是上一次生成的纯函数。这个规则一直有效。

考虑设置如下的初始宇宙:

初始宇宙

我们可以通过考虑每一个细胞来确定下一代。最左上角的细胞已经死亡,第 4 条规则是唯一一个能够处理死亡细胞的规则。所以第 1 排的所有细胞都有相同的规则。他们都没有三个活着的邻居。只能保持死亡。

当我们看到最上面的活着的细胞时,这个游戏开始变得有趣了。在第 2 排第 3 列。对于活着细胞前三个规则都可以应用。对于这一个细胞,他只有一个活着的邻居,所以规则一可用。这个细胞会在下一次争执死亡。下面那几个活着的细胞也是有一样的命运。

中间的活着的细胞,还有两个邻居,上面的和下面的,这就意味着它符合规则二,他可以活到下一次增值。

最后一个比较有趣的例子,就是当我们看到死掉的细胞。嗯。在中间这活着的细胞的左边和右边。这三个活着的细胞都是他们的邻居。这使得他们按照规则是可以在下一轮重生。

将这些规则放在一起,我们可以获得下一刻的世界。

下一刻的世界

根据这个例子,和确定的规则。不去并精彩的事情将会发生。

Gosper's glider gun

Pulsar

Space ship

练习

手动计算出下一刻,宇宙应该是什么样

答案,不许看!

下一刻宇宙

你能找到一个稳定的没有变化的宇宙吗?

答案,不许看!

这个答案,不许看!其实有无数个,最平凡的答案,不许看!就是它是一个空宇宙。如果是一个 2×2 的方格,也可以形成一个稳定的宇宙。

实现 Conway 的生命游戏

设计

在开始之前呢,我们要先考虑以下几种设计模式。

无限宇宙

生命游戏是在一个无限宇宙中玩的。但是我们没有无限的内存和计算能力。在这种情况下,我们往往会有三个选项。

  1. 始终追踪这个宇宙的发展,并适当的扩展宇宙。这个扩张是无限的,所以这个实现实现了就会逐渐逐渐的变得越来越慢,直到把内存全部用完。
  2. 创建一个固定的宇宙,当细胞碰到宇宙的边缘的时候,将会有更少的邻居。更简单的策略就是当他们已经达到边缘的时候,直接被宇宙剪掉。
  3. 创建一个固定的宇宙,当细胞达到边缘的时候,将会从另外一边滑入这样,我的我们的应用就可以一直跑下去。

我们会按照第 3 个选项来实现。

连接 Rust 和 JavaScript

此部分是本人最重要的一节。

JavaScript 的垃圾回收堆内存,是用来调用 Object 和 Array 还有 DOM 结点的。而 Rust 存在的 WebAssembly 线性内存和它是截然不同的。WebAssembly 目前还不能直接操作垃圾回收堆内存(在 2018 年 4 月,一个关于接口类型(Interface Type)的提案将会改变这一局面)。JavaScript 却可以读写 WebAssembly 的线性内存,但仅限于 ArrayBuffe 支持的标量(u8, i32, f64 等等)。WebAssembly 行数一样能处理和返回这些标量。以下讲解 WebAssembly 和 JavaScript 如何链接。

wasm_bindgen 定义了如何穿过这段链接计算数据结构的方法。它包括装箱 Rust 结构,并包装指针成为一个 JavaScript 类以供使用,或者提供 JavaScript 对象给 Rust 使用。wasm_bindgen 非常便利,但并不是无需考虑怎样在这个链接上传输数据结构。你应该把它当作一个实现接口的工具。

当设计 WebAssembly 和 JavaScript 的接口时,我们需要考虑到以下内容。

  1. 减少复制到和移出 WebAssembly 线性内存中的值,无效的复制会造成无用的性能损耗。
  2. 最小的序列化和解序列化,和复制类似,序列化和解序列化一样造成性能损耗,如果想要把数据无副作用地从一端传到另一端,与其说在一端序列化,到另一端解序列化,不如使用 wasm_bindgen 帮助我们将 JavaScript 的 Object 装箱成 Rust 的 structure。

一个结论,处理 JavaScript 和 WebAssembly 接口设计时,经常将大的、生命周期长的数据结构作为 Rust 类型,存储在 WebAssembly 线性内存中,并给 JavaScript 暴露一个处理方法,JavaScript 调用 WebAssembly 转换文件,处理运算,并最终得到一个小的,可复制的结果。通过只返回计算结果,我们可以躲过复制和序列化数据的过程。

在生命游戏中链接 Rust 和 JavaScript

接下来结局几个要规避的问题。我们不想每刻都复制整个宇宙到 WebAssembly 的内存中,我们不想处理宇宙中所有的细胞,也不想在每次读写细胞的时候都穿过 WebAssembly 和 JavaScript 的分界。

这是我们的 4x4 宇宙在内存中的结构。

4x4宇宙在内存中的结构

为了寻找细胞在内存中的位置,我们可以使用下面的公式。

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

我们有很多方法来给 JavaScript 暴露宇宙中的细胞。开始我们要为宇宙实现一个std::fmt::Display。我们可以使用一个 Rust 的 String,每个字符代表一个细胞。这个 Rust 的 string 将会从 WebAssembly 的内存中复制到 JavaScript 的内存里,并接下来作为 textContent 展示到 HTML 里面。本节的后面,将会讲到如何把细胞展示到 canvas 中。

另一种设计是让 Rust 返回每个细胞的生存状态列表,这样 JavaScript 就不需要在渲染时解析整个宇宙,这不过这个是先更加复杂些。

Rust 的实现

上一章,我们复制了初始化模板,我们现在要修改这个模板。

从删除 greet 函数,并定义宇宙中的细胞开始。

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

#[repr(u8)]很重要,这样每个细胞都会以一个字节存储,另外 Alive 为 1,Dead 为 0 也很重要,这样我们就可以使用加法计算邻居数目。

接下来定义宇宙,一个宇宙包括宽度,高度和一个向量的细胞。

#[wasm_bindgen]
pub struct Universe {
    width: u32,
    height: u32,
    cells: Vec<Cell>,
}

访问并转换细胞的实现如下。

impl Univers {
    fn get_index(&self, row: u32, column: u32) -> usize {
        (row*self.width + column) as usize
    }
}

为了计算细胞接下来的状态,我们要统计某个细胞有多少个邻居存活。

impl Univers {
    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
    }
}

这个函数使用取余处理边界问题。现在我们已经有所有的必须函数了,最后只需要生成下一刻的状态即可(记住,每个函数必须在#[wasm_bindgen]属性之下,这样 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) {
                    (Cell::Alive, x) if x < 2 => Cell::Dead,
                    (Cell::Alive, 2) | (Cell::Alive, 3) => Cell::Alive,
                    (Cell::Alive, x) if x > 3 => Cell::Dead,
                    (Cell::Dead, 3) => Cell::Alive,
                    (otherwise, _) => oterwise,
                };

                next[idx] = next_cell;
            }
        }
        self.cells = next;
    }
}

目前为止,一个宇宙的状态就都被存储在 cell 这个向量里面了。为了提高它的可读性,让我们实现一个文本渲染器,目的是将整个宇宙按行输出为文字,每一个活着的细胞标注为 Unicode 符号“■”,死掉的细胞则为“□”。

通过实现 Rust 标准库中的Displaytrait,我们可以将数据结构以一种用户交互方式输出,它也提供了一个to_string方法。

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, "\n")?;
      }
    }

    Ok(())
  }
}

最后,我们定义一个构造器去初始化一个有趣的图案和一个渲染函数。

#[wasm_bindgen]
impl Universe {
  pub fn new() -> {
    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 部分已经完工。

使用 JavaScript 渲染

首先在 HTML 中插入

标签用来展示整个宇宙。

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

另外我们希望

标签能处于页面中央。我们可以通过 CSS flex box 实现这个任务,在 html 中增加
body {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
}

修改 JavaScript 入口文件,将原来引入的 greet 函数改为 Universe。

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

让我们在那个

标签中增加新的宇宙实例吧。

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

使用 JavaScript 创建一个 requestAnimationFrame 循环,每一次循环,就在

标签中绘制一遍宇宙,并执行一次Universe::tick

function renderLoop() {
  pre.textContent = universe.render();
  universe.tick();

  requestAnimationFrame(renderLoop);
}

想要实现渲染,只需执行requestAnimationFrame(renderLoop)

确保你的本地服务任然在运行,此时你的页面应该如下所示。

浏览器页面

渲染到 Canvas 上

在 Rust 中生成字符串并通过 wasm-bindgen 拷贝到 JavaScript 中做了很多无关的复制。既然 JavaScript 已经知道宇宙的长度和宽度,而且 JavaScript 本来可以直接读 WebAssembly 的内存,我们将要修改 render 方法,直接返回细胞向量的指针。

同时,与其渲染 Unicode 字符,不如开始用 Canvas API。接下来我们会开始设计这些。

在 html 中,修改

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

为了能拿到 Rust 中的相关数据结构,我们需要为宇宙增加 getter 函数,暴露宇宙的宽度、高度和细胞的向量。增加如下函数。

#[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()
  }
}

接下来,在 JavaScript 中,引入 Cell,并设置几个渲染画布的常量。

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

const CELL_SIZE = 5;
const GRID_COLOR = "#CCCCCC";
const DEAD_COLOR = "#FFFFFF";
const LIVE_COLOR = "#000000";

接下来修改实现 canvas 的部分。

const universe = Universe.new();
const width = universe.width();
const height = universe.height();

const canvas = documnet.getElementById("game-of-life-canvas");
canvas.height = (CELL_SIZE+1)*height + 1;
canvas.width = (CELL_SIZE+1)*width + 1;

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

function renderLoop() {
  universe.tick();

  drawGrid();
  drawCells();

  requestAnimationFrame(renderLoop);
}

世界的网格,是一系列等宽的竖线和横线。

function drawGrid() {
  ctx.beginPath();
  ctx.strokeStyle = GRID_COLOR;

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

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

  ctx.stroke();
}

我们可以直接访问 WebAssembly 的内存,他是直接定义在wasm_game_of_life_bg。为了画细胞,我们先找到一个细胞的指针,并将它们转换成 Unit8Array,迭代这些细胞,并按照他们的生命状态绘制白色和黑色方块。计量避免复制所有细胞。

import { memory } from "wasm-game-of-life/wasm_game_of_life_bg";

function getIndex(row, column) {
  return row*width+column;
}

function drawCells() {
  const cellsPtr = universe.cells();
  const cells = new Unit8Array(
    memory.buffer,
    cellPtr,
    width*height,
  );

  ctx.beginPath();

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

      ctx.fillStyle = cells[idx] === CellDead
        ? DEAD_COLOR
        : LIVE_COLOR;

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

  ctx.stroke();
}

开始渲染,需要添加以下表达式。

drawGrid();
drawCells();
requestAnimationFrame(renderLoop);

注意 drawGrid 和 drawCell 必须要在 requestAnimationFrame 之前执行。

成功了!

重建 WebAssembly 绑定。

wasm-pack build

确定开发服务器还在运行,如果不是,需要执行以下命令。

npm run start

刷新http://localhost:8080/,你应该能看到如下结果。

页面

结束之前,这里还有一个不错的实现生命游戏的算法,hashlife。它使用缓存,使得程序有指数级性能提升!但是为什么我们不实现它呢?它已经超出本文涉及的范围了,本文只是专注于 Rust 和 WebAssembly 集成,但是我们强烈期望你能实现这一算法。

练习

实现一台宇宙飞船

生成一个随机的初始环境,每个细胞有 50%的生存可能

答案,不许看!

先增加 js-sys 依赖

[dependencies]
js-sys="0.3"

接下来使用 js 的随机函数

extern crate js_sys;

if js_sys::Math::random() < 0.5 {

} else {

}

以 bit 形式存储每个 cell

答案,不许看!

在 Rust 中,使用 fixedbitset 代替Vec<Cell>;

extern crate fixedbitset;
use fixedbitset::FixedBitSet;

#[wasm_bindgen]
pub struct Universe {
  width: u32,
  height: u32,
  cells: FixedBitSet,
}

宇宙的构造器应该这么修改。

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

  let size = (width*height) as usize;
  let mut cells = FixedBitSet::with_capacity(size);

  for i in 0..size {
    cells.set(i, i%2==0 || i%7==0);
  }

  Universe {
    width,
    height,
    cells,
  }
}

使用 FixedBitSet 的 set 方法更新宇宙的下一刻。

next.set(idx, match (cell, live_neighbors) {
  (true, x) if x<2 => false,
  (true, 2) | (true, 3) => true,
  (true, x) if x>3 => false,
  (false, 3) => true,
  (otherwise, _) => otherwise
});

传输指针的时候,需要返回 slice。

#[wasm_bindgen]
impl Universe {
  pub fn cells(&self) -> *const u32 {
    self.cells.as_slice().as_ptr()
  }
}

在 JavaScript 中,构造 Unit8Array 的时候需要除以 8,以为我们是以 bit 存储细胞的。

const cells = new Unit8Array(
  memory.buffer,
  cellsPtr,
  width*height/8
);

通过判断 Unit8Array 是否被赋值而判断细胞是否是活着的。

function bitIsSet(n, arr) {
  const byte = Math.floor(n/8);
  const mask = 1<<(n%8);
  return (arr[byte] & mask) == mask;
}

根据以上变化,新版本的 drawCells 如下。

function drawCells() {
  const cellsPtr = universe.cells();
  const cells = new Unit8Array(
    memory.buffer,
    cellsPtr,
    width*height/8
  );

  ctx.beginPath();

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

      ctx.fillStyle = bitIsSet(idex, cells)
        ? LIVE_COLOR
        : DEAD_COLOR;

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

  ctx.stroke();
}

测试

现在我们已经实现了 Rust 的实现,并成功渲染在浏览器中。现在来谈谈测试 WebAssembly 中的 Rust 函数。

我们将要测试 tick 函数,确保它能返回正确的值。

接下来,我们将处理 Universe 的 setter 函数,让我们能构造不同大小的 universe。

#[wasm_bindgen]
impl Universe {
  pub fn set_width(&mut self, width: u32) {
    self.width = width;
    self.cells = (0..width * self.height).map(|_| Cell::Dead).collect()
  }

  pub fn set_height(&mut self, height: u32) {
    self.height = height;
    self.cells = (0..self.width * height).map(|_| Cell::Dead).collect()
  }
}

我们将会创建另一个不需要#[wasm_bindgen]impl Universe实现,因为我们不能把所有的 WebAssembly 函数暴露给 JavaScript,Rust 生成的 WebAssembly 函数是不能返回引用的。可以尝试让 Rust 返回一个引用,查看一下编译结果中是什么错误。

接下来我们要写一个 get_cells 来获得细胞,和一个 set_cells 来设置哪些细胞是活的,哪些是死的。

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;
    }
  }
}

现在我们将创建测试文件tests/web.rs

在这之前,测试环境已经配置好,请确定wasm-pack test --chrome --headless能够在根目录下运行。你也可以使用--firefox--safari--node选项来在其他浏览器测试你的代码。

test/web.rs中,我们需要到处 Universe 类型。

extern crate wasm_game_of_life;
use wasm_game_of_life:Universe;

在测试文件中,我们要创建一个飞船构造函数。

我们要构造一个 tick 函数执行之前的飞船,和一个 tick 函数执行后的期望值。

#[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 函数,创建以上的两个飞船。最后使用assert_eq!宏比较 expected_ship 来确保 tick 函数运行正确。我们添加#[wasm_bindgen_test]属性保证这个函数可以在 WebAssembly 环境下测试。

#[wasm_bindgen_test]
pub fn test_tick() {
  let mut input_universe = input_spaceship();
  let expected_universe = expected_spaceship();

  input_universe.tick();
  assert_eq!(
    &input_universe.get_cells(),
    &expected_universe.get_cells(),
  )
}

测试这个测试函数使用wasm-pack test --firefox --headless

调试

写这么多代码之前(虽然上面都写完了,我也不知道原作者抽什么风),先看一看 Rust 的调试工具。

调试工具

此部分将会介绍 WebAssembly 的调试工具。

使用 debug 标记编译

如果没有打开 debug 标记,“name”这个部分就不会被编译到二进制程序中,错误栈也不会显示函数名,你会收到wasm-functions[42]而不是wasm_game_of_file::Universe::live_neighbor_count

调试编译,wasm-pack build --debug或者cargo build总是会默认打开 debug 标记。

版本编译(release build),debug 标记是默认关闭的,要打开 debug 标记,需要声明debug=true

[profile.release]
debug = true

使用 console API 打印日志

打印日志是最好的判断程序是否是有错的方式。在浏览器中,console.log函数可以将日志打印到浏览器的 dev 工具里。

我们可以使用 web-sys 包去调用 console API。

extern crate web_sys;

web_sys::console::log_1(&"Hello, world!".into());

相应的console.error函数用法一致,但是浏览器的调用栈还是按照console.error来打印。

使用console.log

使用console.error

打印崩溃日志

console_error_panic_hook包能通过console.error打印崩溃日志。他能打印出格式化的崩溃信息而不是难以理解的RuntimeError: unreachable executed

你只需要增加调用这个钩子函数。

#[wasm_bindgen]
pub fn init_panic_hook() {
  console_error_panic_hook::set_once();
}

使用调试器

不幸的,WebAssembly 的调试器依然不成熟,在很多 unix 系统中,DWARF 是用来解析调试程序需要的数据的工具。虽然,Windows 上面也有一个类似的工具。但还没有相当的工具提供给 WebAssembly。所以,调试器目前能给予的功能有限,我们只能收到 WebAssembly 的错误而不是 Rust 源代码的错误。

这里有一个故事是跟踪 WebAssembly 的调试的,我们希望它将来会有所改善!

尽管如此,调试器还是能够给调试 JavaScript 方面提供效力。

一开始就规避在 WebAssembly 上面使用调试

如果错误和交互 JavaScript 和 Web API 有关,则使用wasm-bindgen-test写测试。

如果和 JavaScript 和 Web API 无关,这是用默认的#[test]属性。使用quickcheck可以减少写测试上面的时间。

为了避免#[test]编译器出现连接错误,你需要一个 rlib 依赖,在Cargo.toml文件按照如下修改。

[lib]
crate-type ["cdylib", "rlib"]

在生命游戏中打开崩溃日志

如果程序崩溃,最好是能够在审查工具中看到日志。

在“src/utils.rs`里面有一个可选的 console_error_panic_hook 包,可以在 Universe 初始化的时候调用它。

pub fn new() -> Universe {
  utils::set_panic_hook();
}

为生命游戏增加日志

让我们在 Rust 中利用 web-sys 调用 console,打印出每一刻的细胞状态。

首先在以来中增加 web-sys,修改 Cargo.toml。

[dependencies.web-sys]
version = "0.3"
features = [
  "console",
]

为了高效,我们把console.log函数封装到println!一样的宏中。

extern crate web_sys;

macro_rules! log {
  ($( $t:tt )*) => {
    web_sys::console::log_1(&format!( $( $t )* ).into());
  }
}

现在可以通过调用 log 发送日志了。

log!(
  "cell[{}, {}] is initially {:?} and has {} live neighbors",
  row,
  col,
  cell,
  live_neighbors,
)

使用调试器

浏览器的调试器在调试 JavaScript 和 Rust 生成的 WebAssembly 很有效。

举个例子,在 renderLoop 函数中增加debugger;可以暂停页面执行的某一刻。

者给予我们查看每一刻细胞状态的能力。

调试画面

练习

  1. 给 tick 方法增加 log,查看细胞状态。
  2. 加入panic!()查看打印出来的崩溃日志。

增加交互

接下来我们要给这个游戏增加一些交互,我们会允许用户选择细胞的生死,并且允许暂停游戏,并使绘制初始图案更加简单。

暂停和继续游戏

首先修改 html,在画布上面增加一个