Перейти к содержанию

Создание небольшой игры с помощью WebAssembly и Rust

Posted on:2 апреля 2023 г. at 09:31

Example Dynamic OG Image link В этой статье я хочу поделиться своим опытом создания игры Snake для браузера с помощью WebAssembly и Rust.

Что такое WebAssembly?

WebAssembly - это независимый от платформы формат для исполняемых программ, предназначенный для компиляции языков программирования. WebAssembly был разработан для запуска в браузере и дополнения JavaScript двумя способами:

WebAssembly (WASM) также становится все более актуальным для серверных вычислений, и Соломон Хайкс, соучредитель Docker, даже написал:

Если бы WASM +WASI существовали в 2008 году, нам не нужно было бы создавать Docker. […] Веб-сборка на сервере - это будущее вычислительной техники.

В этой статье мы сосредоточимся на примере создания браузерной игры.

Почему Rust?

Первоначально WebAssembly был ориентирован на C/C++, но теперь многие языки программирования могут компилироваться в WebAssembly.

Поскольку задачи, требующие больших вычислительных затрат, являются обычным вариантом использования WebAssembly, естественным выбором является его использование с низкоуровневым языком программирования. Что делает Rust особенно привлекательным, так это баланс между акцентом на производительность и избеганием подводных камней, обычно связанных с языками низкого уровня, такими как C/C++.

Согласно отчету, состояние WebAssembly 2022 Rust стал наиболее часто используемым, а также наиболее желанным языком для разработки WebAssembly, что делает его еще более привлекательным по мере роста популярности экосистемы.

Инструменты

Мы использовали рекомендованные Rust модули wasm-pack и wasm-bindgen для компиляции нашего кода Rust в WebAssembly и облегчения взаимодействия с JavaScript. Эти библиотеки создают модуль WebAssembly, а также соответствующие оболочки JavaScript, которые абстрагируют низкоуровневые детали взаимодействия между кодом JavaScript и Rust. Вы можете найти отличное руководство по началу работы здесь.

web-sys модуль предоставила нам импорт на основе wasm-bindgen для API-интерфейсов браузера. Он поставляется с обширным списком примеров.

Для быстрых итераций мы использовали cargo-watch для перестройки при каждом изменении и devserver для горячей перезагрузки в браузере.

Структура игры

В браузере requestAnimationFrame является рекомендуемой основой основного “цикла” рендеринга игры в подходящее время между перерисовками.

function onFrame() {
  requestAnimationFrame(onFrame);
  const canvas = document.getElementById("game").getContext("2d");
  ...
}

onFrame();

Перевод в Rust с помощью wasm-bindgen и web-sys довольно подробный, как вы можете видеть в этом примере requestAnimationFrame и в этом же примере canvas. Веб-API полагаются на динамическую типизацию и отсутствие нулевой безопасности JavaScript и, следовательно, на простые конструкции JavaScript, такие как

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

становятся очень громоздкими при написании с использованием соответствующих безопасных для null и статически типизированных оболочек Rust, предоставляемых web-sys.

let document = web_sys::window().unwrap().document().unwrap();
let canvas = document.get_element_by_id("canvas").unwrap();
let canvas: web_sys::HtmlCanvasElement = canvas
    .dyn_into::<web_sys::HtmlCanvasElement>()
    .map_err(|_| ())
    .unwrap();

let context = canvas
    .get_context("2d")
    .unwrap()
    .unwrap()
    .dyn_into::<web_sys::CanvasRenderingContext2d>()
    .unwrap();

Кроме того, концепция обратных вызовов JavaScript, разделяющая состояние сбора мусора, не переводится в Rust без некоторых трений. Обратные вызовы представлены замыканиями Rust в web-sys. Но концепция владения Rust затрудняет разделение состояния игры между различными замыканиями, используемыми, например, для перерисовки и обработки пользовательского ввода.

WebAssembly явно предназначен не для замены JavaScript, а как дополнение. Общая рекомендация состоит в том, чтобы сохранить интерфейс между JavaScript и WebAssembly простым. Поэтому мы решили предоставить единый контейнер состояния игры в виде структуры с методами для обработки соответствующих событий.

use wasm_bindgen::prelude::*;
use wasm_bindgen::Clamped;
use web_sys::{CanvasRenderingContext2d, ImageData};

#[wasm_bindgen]
pub struct Game {
    ... // состояние игры
}

#[wasm_bindgen]
impl Game {
    pub fn new() -> Game {
        Game {
            ... // состояние игры
        }
    }

    pub fn on_frame(&mut self, ctx: &CanvasRenderingContext2d) {
        ... // обновление состояния игры и отрисовка
    }

    pub fn click(&mut self, x: i32, y: i32) {
        ... // обновление состояния игры
    }
}

Соответствующий JavaScript создает контейнер состояния после загрузки модуля WebAssembly и заботится о регистрации обратных вызовов событий, перенаправляющих на соответствующие методы.

import init, { Game } from "./pkg/snake.js";

const canvas = document.getElementById("canvas");
let game;

init().then(() => {
  game = Game.new();
  canvas.addEventListener("click", event => game.click(event));
  requestAnimationFrame(onFrame);
});

function onFrame() {
  requestAnimationFrame(onFrame);
  game.on_frame(canvas.getContext("2d"));
}

Графика

API браузера предоставляет различные подходы для рендеринга в HTML canvas.

Для полного использования графики с аппаратным ускорением WebGL предоставляет мощный API, и этот API может использоваться через привязки web-sys, но требует заботы о деталях низкого уровня.

Другой подход заключается в рисовании игровой графики с использованием примитивов, таких как прямоугольники и круги Canvas API.

Здесь мы приняли решение в пользу растрирования игровой графики в массив Rust, представляющий отдельные цвета пикселей. Преобразование этого массива Rust в ImageData и рисование его на холсте выполняется с помощью нескольких строк кода.

use wasm_bindgen::prelude::*;
use wasm_bindgen::Clamped;
use web_sys::{CanvasRenderingContext2d, ImageData};

#[wasm_bindgen]
impl Game {
    pub fn on_frame(&mut self, ctx: &CanvasRenderingContext2d) {
        ... // update game state 

        let data = ImageData::new_with_u8_clamped_array_and_sh(
            Clamped(&self.world.screen),
            WIDTH,
            HEIGHT,
        )
        .expect("should create ImageData from array");

        ctx.put_image_data(&data, 0.0, 0.0)
            .expect("should write array to context");
    }
}

Установив для свойства рендеринга изображения canvas значение pixelated, внешний вид поддерживает ретро-стиль игры.

Игровая логика с Rust

Центральной частью состояния игры является массив screen: [u8; SCREEN_SIZE], представляющий цвет каждого пикселя на экране, увеличиваемый массив координат snake: vectdeque <Coord>, содержащий текущее положение змеи, и вектор direction: Coord с текущим направлением движения.

pub struct Coord {
    pub x: i32,
    pub y: i32,
}

pub struct World {
    screen: [u8; SCREEN_SIZE],
    direction: Coord,
    snake: VecDeque<Coord>,
    alive: bool,
}

Основываясь на этой структуре данных, высокоуровневая логика игры в змею может быть реализована путем многократного вычисления нового положения головы змеи и удлинения тела. В зависимости от предыдущего цвета пикселя в новом положении головы мы либо:

В Rust этот алгоритм может быть записан следующим образом.

impl World {
    pub fn on_frame(&mut self) {
        if self.alive {
            let new_head_pos = self.get_new_head();
            let color_at_new_head = self.get_color_at(&new_head_pos);
            self.extend_head_to(&new_head_pos);
    
            match color_at_new_head {
                Color::Food => self.create_food(),
                Color::Snake => self.die(),
                _ => self.shorten_tail(),
            }
        }
    }
}

Вывод

В этой статье мы дали обзор того, как создать небольшую игру с помощью WebAssembly и Rust. Упомянутые инструменты делают реализацию на удивление простой и удобной. Главная задача состояла в том, чтобы найти разумное разделение ответственности между основной логикой Rust и поддерживающим JavaScript-кодом.