В этой статье я хочу поделиться своим опытом создания игры Snake для браузера с помощью WebAssembly и Rust.
Что такое WebAssembly?
WebAssembly - это независимый от платформы формат для исполняемых программ, предназначенный для компиляции языков программирования. WebAssembly был разработан для запуска в браузере и дополнения JavaScript двумя способами:
- обеспечение почти собственной производительности для веб-приложений, таких как игры, редактирование изображений и видео, распознавание изображений и многих других.
- включение выполнения существующего или нового кода, написанного на языках, отличных от 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-кодом.