Rust 開發 WebAssembly 生命遊戲

使用 Rust 開發 WebAssembly,你需要有一定的編程基礎。需要了解 Rust,瞭解 JavaScript,HTML,和 CSS。

本節以生命遊戲爲例進行講解。

1,安裝開發環境

安裝 Rust 工具鏈,安裝 Rust 參考官網,

https://www.rust-lang.org/;

安裝 wasm 目標

rustup target add wasm32-unknown-unknown

安裝 wasm-pack 工具,下載地址:

https://rustwasm.github.io/wasm-pack/installer/

安裝 cargo-generate

cargo install cargo-generate

安裝 npm

https://www.npmjs.com/get-npm

2,初始化項目

使用模板初始化項目,項目名爲 wasm-game-of-life

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

項目生成的目錄結構如下:

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

初始的 lib 模板代碼:

#![allow(unused_variables)]
fn main() {
  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 {
      fn alert(s: &str);
  }
  #[wasm_bindgen]
  pub fn greet() {
      alert("Hello, wasm-game-of-life!");
  }
}

編譯項目,使用命令:

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

將生成的 wasm 文件放置在 Web 項目中,Web 項目也有模板,在項目的根目錄中,使用如下命令生成模板:

npm init wasm-app www

生成的 Web 項目模板目錄結構如下:

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

Web 項目的運行可以使用下面的命令:

npm install 
npm run start

這樣就可以在本地進行開發調試了。

3,開始我們的項目編寫。

生命遊戲的遊戲規則:生命遊戲的宇宙是一個由正方形細胞組成的無限二維正交網格,每個細胞都處於兩種可能的狀態之一,活着或死了。每個細胞與其八個相鄰細胞相互作用,即水平、垂直或對角相鄰的細胞。在每個時間週期內,都會發生以下轉換:活細胞的相鄰活細胞少於兩個的會死亡,就好像是由人口不足引起的。活細胞的相鄰活細胞有兩個或三個會存活到下一代。活細胞的相鄰活細胞有三個以上的會死亡,就好像是由人口過多引起的。死細胞的相鄰活細胞只有三個的會復活,就好像是通過繁殖一樣。最初的模式構成了系統的種子。第一代是種子中的每個細胞通過上述規則而產生的,死亡和生存會同時發生,直到達到平衡。

實現生命遊戲的製作,生命的遊戲是在一個無限的宇宙中進行的,但我們沒有無限的存儲和計算能力。解決這個相當煩人的限制通常有三種方式,1,跟蹤宇宙的哪個子集發生了有趣的事情,並根據需要擴展這個區域。在最壞的情況下,這種擴展是無限制的,實現將變得越來越慢,最終耗盡內存。2,創建一個固定大小的宇宙,其中邊緣的細胞比在中間的細胞有更少的鄰居。這種方法的缺點是,像滑翔機一樣,到達宇宙盡頭的無限模式會被扼殺。3,創建一個固定大小的週期性宇宙,其中邊緣的細胞具有環繞到宇宙另一側的鄰居。因爲鄰居環繞着宇宙的邊緣,滑翔機可以永遠運行。我們將實施第三種選擇。

Rust 和 JavaScript 如何牽手?

JavaScript 的垃圾收集堆 - 存放對象、數組和 DOM 節點的地方,WebAssembly 的線性內存空間,存放 Rust 的值的地方。WebAssembly 目前無法直接訪問垃圾收集堆(截止 2018 年 4 月,這一點預計將隨着 “接口類型” 提案的提出而改變)。但是,JavaScript 可以讀取和寫入 WebAssembly 的線性內存空間,但只能使用標量值(u8,i32,f64)的 ArrayBuffer。WebAssembly 函數也可以獲取和返回標量值。這些是構成所有 WebAssembly 和 JavaScript 通信的基礎。

wasm_bindgen 定義了通用的方法,在兩種語言邊界如何解釋複雜結構體。它可以裝箱 Rust 結構體,在 JavaScript 類中封裝指針,或從 Rust 中索引到 JavaScript 對象表中。wasm_bindgen 非常方便,你可以選擇它實現接口設計。

在設計 WebAssembly 和 JavaScript 之間的接口時,我們希望優化以下屬性:最小化對 WebAssembly 線性內存的複製,不必要的拷貝會帶來不必要的開銷。最小序列化和反序列化,和複製相似,序列化和反序列化也會帶來開銷,而且通常附帶複製。如果我們可以將句柄傳遞給數據結構,而不是在一端進行序列化,將其複製到 WebAssembly 線性內存中的某個已知位置,然後在另一端對其進行反序列化,我們通常可以減少大量開銷。wasm_bindgen 幫助我們定義和使用 JavaScript 對象或裝箱 Rust 結構體的不透明句柄。根據經驗,一個好的 JavaScript<->WebAssembly 接口設計通常將大型、長生命週期的數據結構實現爲駐留在 WebAssembly 線性內存中的 Rust 類型,並作爲不透明句柄暴漏在 JavaScript 中。JavaScript 調用導出的 WebAssembly 函數,這些函數接受這些不透明的句柄,轉換其數據,執行繁重的計算,查詢數據,並最終返回一個小的,可複製的結果。通過只返回較小的計算結果,我們可以避免在 JavaScript 垃圾收集堆和 WebAssembly 線性內存之間來回複製和序列化這些數據。

讓我們從生命遊戲中的宇宙開始,我們不想每次都把整個宇宙複製到 WebAssembly 線性內存中。我們不想爲宇宙中的每個單元分配對象,也不想使用一個跨邊界的調用來讀取和寫入每個單元。我們可以將宇宙表示爲一個平面數組,該數組位於 WebAssembly 線性內存中,每個單元格都有一個字節,0 代表死細胞,1 代表活細胞。

下面代表一個 4*4 的宇宙的平面數組。

要找到宇宙中的給定行和列的單元格的數組索引,我們可以使用下面的公式:

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

我們有幾種方法將宇宙中的單元細胞內容暴漏給 JavaScript,先從簡單的說起,我們呈現爲文本字符,然後通過 WebAssembly 線性內存複製到 JavaScript 中,然後通過 HTML 文本對象來顯示。我們也可以改進,避免在堆之間複製宇宙的單元,直接通過 HTML Canvas 進行渲染。另一種複雜的方法,Rust 每次生命細胞迭代後,將變化的單元格組成列表,暴漏給 JavaScript 中,這樣 JavaScript 渲染時就不需要在整個宇宙中迭代,只需要在相關的子集中迭代。這種方案比較難實現,我們暫不考慮。

我們要開始真正的編碼了,打開 src/lib.rs 文件,定義細胞

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

這裏說明一下,#[repr(u8)] 表示每個單元格都是一個字節。並且 Dead 爲 0,Alive 爲 1,這樣我們可以通過加法計算周邊的活細胞鄰居。

下一步,我們定義宇宙,宇宙擁有寬和高,以及一個長度爲寬乘高的細胞數組。

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

在宇宙中獲取某個細胞的方法,

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

計算宇宙中某個細胞周邊的活鄰居數量的方法,

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

這個方法,巧妙的使用了 delta,當碰到邊界時,使用行或列加上變量對行或高求模,不會出現負數。否則,就得使用 if 條件進行判斷。

現在,我們有了從當前代計算下一代的所需的一切,遊戲的每一條規則都遵循一個簡單的翻譯,即匹配表達式上的條件。此外,因爲我們希望 JavaScript 控制 ticks 何時發生,所以我們將把這個方法放在 #[wasm_bindgen] 塊中,這樣它就可以暴漏在 JavaScript 中。

/// Public methods, exported to 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) {
                    // Rule 1: Any live cell with fewer than two live neighbours
                    // dies, as if caused by underpopulation.
                    (Cell::Alive, x) if x < 2 => Cell::Dead,
                    // Rule 2: Any live cell with two or three live neighbours
                    // lives on to the next generation.
                    (Cell::Alive, 2) | (Cell::Alive, 3) => Cell::Alive,
                    // Rule 3: Any live cell with more than three live
                    // neighbours dies, as if by overpopulation.
                    (Cell::Alive, x) if x > 3 => Cell::Dead,
                    // Rule 4: Any dead cell with exactly three live neighbours
                    // becomes a live cell, as if by reproduction.
                    (Cell::Dead, 3) => Cell::Alive,
                    // All other cells remain in the same state.
                    (otherwise, _) => otherwise,
                };
                next[idx] = next_cell;
            }
        }
        self.cells = next;
    }
    // ...
}

到目前爲止,宇宙的狀態表示爲細胞的矢量。爲了使這個文本可讀,我們實現一個文本呈現器。這個想法就是將宇宙一行一行寫成文本,併爲每個活細胞打印爲◼,每個死細胞,打印爲◻。

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

是時候生成一個宇宙了,

/// Public methods, exported to JavaScript.
#[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-pack build 命令編譯。得到生命遊戲的 wasm 文件。

開始編寫 JavaScript 端的代碼。打開 www/index.html 文件,將內容調整爲:

<body>
  <pre></pre>
  <script src="./bootstrap.js"></script>
</body>

添加了 元素,用來顯示生命遊戲。

我們寫一些 CSS 樣式,將宇宙放在頁面的中間,在 index.html 頁面中的 head 標籤中添加如下代碼:

<style>
  body {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
  }
</style>

在 www/index.js 文件中,添加如下內容,

import { Universe } from "wasm-game-of-life";
const pre = document.getElementById("game-of-life-canvas");
const universe = Universe.new();
const renderLoop = () => {
  pre.textContent = universe.render();
  universe.tick();
  requestAnimationFrame(renderLoop);
};
requestAnimationFrame(renderLoop);

正常運行 Web 項目後,界面如下:

還可以進行優化,使用 Canvas 進行渲染!下節吧。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/X9ooJKpl2TC6rPIrwDI97A