[英] 用 Rust 写一个基于 WebAssembly 的 Canvas 火箭游戏

2,945 阅读5分钟
原文链接: aochagavia.github.io

Two weeks ago, Alex Crichton’s PR adding a target for WebAssembly to the Rust compiler was merged. There are many differences between this target and the Emscripten one, but the important one for me is that it doesn’t depend on external stuff like the Emscripten SDK (which IIRC used to be a pain to get working on Windows, but seems to be better now).

After seeing the examples on hellorust.com, I thought it would be interesting to try to adapt my game Rocket to work on the browser through the wasm32-unknown-unknown target. The project was a great way to figure out how far you can go when porting a project that is a bit more complex than a hello world. I was pleasantly surprised by the fact that most of the code could be reused. Particularly, the game logic code was barely touched at all.

TLDR

Here is the source code. Also, you can play the game in the canvas below or on a dedicated tab.

The controls are:

  • Left and right arrows: turn left and right
  • Up arrow: boost
  • Space: shoot

An MVP

Getting things to compile: removing Piston

Before I started, the little I knew about WebAssembly was that it doesn’t allow you to interface with the OS, graphics card or other stuff like that. Using Emscripten seems to be a way around this problem, but I guess you still need to adapt your programs to some extent… I have never used it, though, so take my words with a grain of salt.

After cloning the Rocket repository I started removing stuff. The first thing to go was the dependency on Piston. I didn’t even try to compile Rocket to wasm before this step, as it is obvious that Piston requires OS support.

At this point, we were left with:

  1. No game loop
  2. No rendering
  3. No player input

Rebuilding the game: laying down the basic structure

So here we are without even a main function. This means that the game loop should be implemented in Javascript and call into our Rust functions. Therefore we need a set of basic functions that are enough to drive the execution of the game, draw something to the screen and process user input.

Since rendering and processing player input are more involved than just updating the game state, I chose the latter as a first function to implement. I was able to reuse the code for the game logic without any change, so the function ended up looking as follows:

#[no_mangle]
pub extern "C" fn update(time: c_double) {
  let data: &mut GameData = &mut DATA.lock().unwrap();
  data.time_controller.update_seconds(time, &data.actions, &mut data.state);
  CollisionsController::handle_collisions(&mut data.state);
}

Surprisingly, the update function on the original game is exactly the same, with the exception of the use of DATA. By the way, we use DATA to store state instead of passing it between Javascript and Rust every time we call a function. The definition is quite simple:

lazy_static! {
  static ref DATA: Mutex<GameData> = Mutex::new(new_game_data(1024.0, 600.0));
}

Since DATA is accessible from anywhere in the program, Rust forces us to use a Mutex to ensure thread safety. Technically, this isn’t necessary in the case of Javascript, since there will only be one thread. Still, the type system knows nothing about that… Hence the mutex.

Getting things to compile, take two

With Piston out of the way, I set out to get the rest of the code to compile and to run it in the browser as a simulation without any visual output. This is the moment where difficulties started to pop out.

The first problem I encountered was caused by the dependency on rand. Generating random numbers doesn’t necessarily require OS support, but you need to generate a seed some way or another. For this reason, rand relies on an OsRng struct that is platform-dependent. Guess what… WebAssembly didn’t had such a struct, so the crate could not be compiled.

Fortunately, the problem was easily solved by adding such a struct. After patching the crate, the code finally compiled… but it didn’t run in the browser.

By the way, you are probably wondering about the seeding problem. If there is no way to communicate with the outside world from your WebAssembly programs, how can you get a seed? Below I will describe how you can call Javascript functions from Rust, which could be a solution to the problem. However, I decided to use a constant seed, which is clearly not optimal, but is good enough for a playable demo.

I mentioned in the paragraph above that the resulting program didn’t run on the browser. Concretely, after following the instructions on hellorust.com, I got the following error:

TypeError: import object field 'env' is not an Object

After looking around for a while, this turned out to be a linking problem. In other words, the generated Rust code contained calls to functions that didn’t exist. Therefore, the browser expected me to pass an import object containing said functions. It seems that some f64 functions I used in the physics part of the game have no analogous on WebAssembly, so I had to pass them explicitly from Javascript through the following object:

let imports = {
  env: {
    Math_atan: Math.atan,
    sin: Math.sin,
    cos: Math.cos
  }
};

After this, the code compiled and could be loaded on the browser, though without any kind of visual feedback. Rust running on the browser! Finally.

Making the game actually playable

Rendering

At this point I discovered that you could call Javascript functions from within the Rust program. This follows the same principle as using C functions from a library. On the Rust side, you need to declare the function as extern. On the Javascript side, you need to add the function to the imports, so it can be linked.

This means we can define drawing functions on the Javascript side and call them from Rust. Even though WebAssembly itself cannot interact with the outside world, it can still call Javascript functions you explicitly pass through the imports object. This will be our escape hatch to render the game to a canvas

Rendering things to the screen was as easy as adding a bunch of functions to my program:

extern "C" {
    fn clear_screen();
    fn draw_player(_: c_double, _: c_double, _: c_double);
    fn draw_enemy(_: c_double, _: c_double);
    fn draw_bullet(_: c_double, _: c_double);
    fn draw_particle(_: c_double, _: c_double, _: c_double);
    fn draw_score(_: c_double);
}

Of course, these functions had to be implemented on the Javascript side. You can find them on the source code of the demo. You won’t find any surprises there, as the only thing they do is drawing to a canvas.

With these extern functions in place, I could implement the rest of the drawing code in Rust as shown below:

#[no_mangle]
pub unsafe extern "C" fn draw() {
    use geometry::{Advance, Position};
    let data = &mut DATA.lock().unwrap();
    let world = &data.state.world;

    clear_screen();
    for particle in &world.particles {
        draw_particle(particle.x(), particle.y(), 5.0 * particle.ttl);
    }

    for bullet in &world.bullets {
        draw_bullet(bullet.x(), bullet.y());
    }

    for enemy in &world.enemies {
        draw_enemy(enemy.x(), enemy.y());
    }

    draw_player(world.player.x(), world.player.y(), world.player.direction());
    draw_score(data.state.score as f64);
}

Again, if you compare this code to the original version, you will see that they are strikingly similar.

Processing user input

With simulation and rendering in place, enabling user input was almost trivial. First of all, I added a bunch of functions to toggle user actions on and off. Note that I am using a Rust type as a parameter of each function. This is technically incorrect, but I am not sure about which type I should use instead. If you do, please open a PR so it can be fixed.

#[no_mangle]
pub extern "C" fn toggle_shoot(b: bool) {
    let data = &mut DATA.lock().unwrap();
    data.actions.shoot = b;
}

#[no_mangle]
pub extern "C" fn toggle_boost(b: bool) {
    let data = &mut DATA.lock().unwrap();
    data.actions.boost = b;
}

#[no_mangle]
pub extern "C" fn toggle_turn_left(b: bool) {
    let data = &mut DATA.lock().unwrap();
    data.actions.rotate_left = b;
}

#[no_mangle]
pub extern "C" fn toggle_turn_right(b: bool) {
    let data = &mut DATA.lock().unwrap();
    data.actions.rotate_right = b;
}

In this case, the code did differ considerably from the original version, since the latter relies on the piston_window::Key struct, which no longer exists. In the wasm version, I moved the key matching logic to Javascript, since I didn’t want to pass strings between Javascript and Rust. The resulting code is straightforward:

// Input processing
function processKey(key, b) {
  switch (key) {
    case "ArrowLeft":
      module.toggle_turn_left(b);
      break;
    case "ArrowRight":
      module.toggle_turn_right(b);
      break;
    case "ArrowUp":
      module.toggle_boost(b);
      break;
    case " ":
      module.toggle_shoot(b);
      break;
  }
}
document.addEventListener('keydown', e => processKey(e.key, true));
document.addEventListener('keyup', e => processKey(e.key, false));

Conclusion

Even though the wasm32-unknown-unknown target is quite new, it clearly has a lot of potential. I am impressed by the fact that I was able to port Rocket with almost no modifications to the game logic code. In the end, I ended up spending most of the time dealing with rendering and figuring out how to correctly set up the integration between Javascript and Rust.