Это предпоследняя часть серии статей по разработке симуляции эволюции с помощью нейронной сети и генетического алгоритма.



В сегодняшнем выпуске:


Сексуальные многоугольники





Сертифицированные ISO диаграммы ASCII


------------
| \...%....|
|   \......|
|    @>....|
|      \...|
|        \.|
------------

Клевые числа





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


Предупреждение: эта статья содержит код JavaScript.


Не беспокойтесь, если JavaScript (далее также — JS) или HTML вам незнакомы, я постараюсь объяснить все концепции по ходу дела.

Проект


У нас есть struct NeuralNetwork и struct GeneticAlgorithm, а как насчет struct Eye или struct World? Ведь у наших птиц должны быть глаза и место для жизни!





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


Если вам нравятся диаграммы, то мы стремимся к следующему:





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


Готовы? Тогда вперед.


Предварительные условия


Для работы с WebAssembly (далее также — WA) нам понадобятся два дополнительных инструмента:


  • npm (Cargo для JS)
  • wasm-pack (набор инструментов, облегчающий компиляцию WA в Rust)

Прежде чем продолжить, установите эти инструменты.


Привет, WA (Rust)!


Начнем с создания нового крейта, отвечающего за взаимодействие с фронтендом:


Формально такой модуль для "общения с другой системой" называется мостом (bridge) или модулем взаимодействия (interop).

cd shorelark/libs
cargo new simulation-wasm --lib --name lib-simulation-wasm

Для того, чтобы наш крейт поддерживал WA, нам нужно добавить в его манифест 2 вещи:


  1. Нам нужно установить crate-type в значение cdylib:

[package]
# ...

[lib]
crate-type = ["cdylib"]

Компилятор преобразует код в нечто, а crate-type определяет, каким будет это нечто, также называемое артефактом:
  • crate-type = ["bin"] означает: компилятор, пожалуйста, сгенерируй программу (например, файл .exe на Windows)
  • create-type = ["lib"] означает: компилятор, пожалуйста, сгенерируй библиотеку


Где:
  • программа — это нечто, что мы можем вызывать напрямую (например, в терминале)
    библиотека — код, предоставляющий функционал для других крейтов


Тип, который нам нужно использовать, cdylib, обозначает динамическую библиотеку C и сообщает компилятору следующее:
  • он должен экспортировать только те функции, которые предназначены для вызова извне, игнорируя внутренние особенности Rust.


Это предотвращает раздувание библиотеки "бесполезными" метаданными, что важно для WA (мы не хотим, чтобы наши пользователи обанкротились из-за счетов за Интернет, не так ли?).
  • он должен генерировать динамическую библиотеку, т.е. фрагмент кода, который будет вызываться кем-то другим.


Это необходимо для WA, потому что, как вы вскоре увидите, наш код Rust не будет работать автономно: он будет предоставлен в полное распоряжение JS.

На практике это означает, что у нас не будет никаких fn main() { ...​ }, а будет pub fn do_something() { ...​ }.

  1. Нам нужно включить wasm-bindgen в наши зависимости:

# ...

[dependencies]
wasm-bindgen = "0.2"

wasm-bindgen предоставляет типы и макросы, упрощающие написание кода, компилируемого в WA.

Rust + WA можно писать и без него, просто это будет менее удобным.

С настройками закончили, давайте писать код!


Поскольку создание симуляции займет некоторое время, а было бы неплохо увидеть, как что-то работает как можно скорее, я предлагаю начать с создания функции, которую мы сможем вызывать из JS, просто для того, чтобы убедиться, что эта штука на WA не обман:


pub fn whos_that_dog() -> String {
    "Mister Peanutbutter".into()
}

Если бы мы создавали обычный крейт для публикации на crates.io, этого было бы достаточно, но для WA нам нужно добавить еще одну вещь, #[wasm_bindgen]:


use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn whos_that_dog() -> String {
    "Mister Peanutbutter".into()
}

Немного упрощая, можно сказать, что процедурный макрос #[wasm_bindgen] сообщает компилятору, что мы хотим экспортировать данную функцию или тип, т.е. сделать ее видимой на стороне JS.

Все символы Rust в конечном итоге компилируются в WA, но только те, к которым добавлен #[wasm_bindgen], могут вызываться напрямую из JS.

Чтобы создать крейт WA, воспользуемся только что установленным wasm-pack:


cd simulation-wasm
wasm-pack build

Разница между обычным cargo и wasm-pack заключается в том, что последний не только компилирует код, но и генерирует множество полезных файлов JS, которые в противном случае нам пришлось бы писать вручную. Эти файлы можно найти внутри только что созданной директории pkg:


ls -l pkg
total 36
-rw-r--r-- 1 pwy   110 Apr  2 17:20 lib_simulation_wasm.d.ts
-rw-r--r-- 1 pwy   184 Apr  2 17:20 lib_simulation_wasm.js
-rw-r--r-- 1 pwy  1477 Apr  2 17:20 lib_simulation_wasm_bg.js
-rw-r--r-- 1 pwy 13155 Apr  2 17:20 lib_simulation_wasm_bg.wasm
-rw-r--r-- 1 pwy   271 Apr  2 17:20 lib_simulation_wasm_bg.wasm.d.ts
-rw-r--r-- 1 pwy   356 Apr  2 17:20 package.json

Кратко рассмотрим, что у нас есть:


  • package.json — это как Cargo.toml для npm, содержит метаданные модуля:

{
  "name": "lib-simulation-wasm",
  "version": "0.1.0",
  /* ... */
}

  • lib_simulation_wasm.d.ts содержит определения типов (forward declarations), которые используются IDE для подсказок:

/**
 * @returns {string}
 */
export function whos_that_dog(): string;

  • lib_simulation_wasm_bg.wasm содержит байт-код WA нашего крейта; это похоже на .dll или .so, и мы можем использовать wabt для его изучения (в основном для развлечения, полагаю):

(module
  (func (type 1) (param i32) (result i32)
    (local i32 i32 i32 i32)
    global.get 0
    i32.const 16
    i32.sub
    local.tee 11
    ;; ...

  • lib_simulation_wasm_bg.js содержит довольно пугающий код, который фактически вызывает нашу библиотеку WA:

/**
 * @returns {string}
 */
export function whos_that_dog() {
    try {
        const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
        wasm.whos_that_dog(retptr);
        var r0 = getInt32Memory0()[retptr / 4 + 0];
        var r1 = getInt32Memory0()[retptr / 4 + 1];
        return getStringFromWasm0(r0, r1);
    } finally {
        wasm.__wbindgen_add_to_stack_pointer(16);
        wasm.__wbindgen_free(r0, r1);
    }
}

Если наш код Rust почти ничего не делает:

pub fn whos_that_dog() -> String {
    "Mister Peanutbutter".into()
}

… почему сгенерированного кода так много?

Вкратце, это связано с тем, что WA не поддерживает строки, и wasm-pack пытается это прозрачно обойти.

Но сначала терминология:
  • когда мы перемещаем значение из одной функции в другую, когда обе функции работают в разных средах и/или когда они написаны на разных языках, мы заставляем значение пересечь границу интерфейса внешней функции (foreign function interface, FFI):





В этом случае мы говорим, что функция whos_that_dog() возвращает строку, которая пересекает границу FFI из Rust (где она создается) в JS (где она используется).

Пересечение границы FFI — это большое дело, потому что разные языки часто по-разному представляют объекты в памяти. Поэтому, даже если struct Foo в Rust и class Foo в JS на первый взгляд выглядят одинаково, в памяти они хранятся по-разному.

Это означает, что когда мы хотим отправить значение из одного языка в другой, мы не можем просто сказать: "Эй, по адресу 0x0000CAFE есть несколько байтов — это Foo". Вместо этого, нам нужно преобразовать это значение в то, что сможет понять другая сторона:




  • преобразование значения в другое представление называется сериализацией (serialization).


Например, такой тип:

struct Foo {
    value: String,
}

… может быть сериализован в, скажем, такой JSON:

{
  "value": "Hi!"
}

… который затем может быть легко десериализован (deserialize) на стороне JS:

const foo = JSON.parse('{ "value": "Hi!" }');
console.log(foo);

Сериализация не ограничивается удобочитаемыми форматами, такими как JSON, YAML или XML. Существуют также такие форматы как Protocol Buffers.

Хотя и Rust, и JS поддерживают строки, WA понимает в основном числа. Это означает, что все функции, которые мы экспортируем через #[wasm_bindgen], могут принимать и возвращать максимум несколько чисел (с точки зрения WA).

Это означает, что для возврата строки wasm-pack пришлось проявить творческий подход:

pub extern "C" fn __wasm_bindgen_generated_whos_that_dog()
    -> <String as ReturnWasmAbi>::Abi
{
    let _ret = { whos_that_dog() };
    <String as ReturnWasmAbi>::return_abi(_ret)
}

Это известно как шим (shim) (или функция склеивания (glue-function), или склеивающий код (glue-code)).

Это конвертирует строку Rust в пару чисел:
  • r0, определяющее локацию возвращаемой строки в памяти (старый-добрый указатель)
  • r1, определяющее длину возвращаемой строки


Эти два числа затем используются функцией getStringFromWasm0() для воссоздания ("десериализации") строки на стороне JS — и все это без нашего участия.

Итак, мы скомпилировали крейт, но как его запустить?


Привет, WA (JS)!


Время фронтенда!


Для запуска фронтенда вернемся в корневую директорию проекта:


cd ../..

Для настройки фронтенда также требуется немного шаблонного кода. К счастью, на этот раз мы можем использовать команду npm init, чтобы скопировать и вставить для нас шаблон фронтенда WA:


npm init wasm-app www

Выполнение данной команды приводит к генерации директории www со следующими файлами:


ls -l www
total 248
-rw-r--r-- 1 pwy  10850 Apr  2 17:24 LICENSE-APACHE
-rw-r--r-- 1 pwy   1052 Apr  2 17:24 LICENSE-MIT
-rw-r--r-- 1 pwy   2595 Apr  2 17:24 README.md
-rw-r--r-- 1 pwy    279 Apr  2 17:24 bootstrap.js
-rw-r--r-- 1 pwy    297 Apr  2 17:24 index.html
-rw-r--r-- 1 pwy     56 Apr  2 17:24 index.js
-rw-r--r-- 1 pwy 209434 Apr  2 17:24 package-lock.json
-rw-r--r-- 1 pwy    937 Apr  2 17:24 package.json
-rw-r--r-- 1 pwy    311 Apr  2 17:24 webpack.config.js

Кратко рассмотрим их.


Знай своего врага


Точкой входа (main.rs в Rust), является index.html:


<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Hello wasm-pack!</title>
  </head>

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

Краткий обзор HTML:


  • <something> называется тегом (tag)
  • теги бывают открывающие (<something>) и закрывающие (</something>)
  • тег может содержать атрибуты: key="value"
  • тег может содержать детей (children) (другие теги)

В общем, документ HTML описывает представление веб-страницы в виде дерева (это называется объектным представлением документа (document object model, DOM)):


html
├── head
│   ├── meta
│   └── title
└── body
    └── script

… которое браузер анализирует, пытаясь сделать из него что-то приятное.


Каждый тег имеет определенное значение:


  • html оборачивает весь документ
  • head содержит метаданные документа (такие как язык или заголовок)
  • body оборачивает содержимое документа
  • script загружает и выполняет файл JS
  • p (отсутствует в примере) оборачивает текст
  • b (отсутствует в примере) делает текст полужирным и т.д.

<body>
  <p>yes... ha ha ha... <b>yes!</b></p>
</body>




Мы видим, что наша страница делает не так уж много — самое главное, что она загружает bootstrap.js:


import("./index.js")
  .catch(e => console.error("Error importing `index.js`:", e));

import — это как use или extern crate в Rust. В отличие от Rust, в JS import может использоваться как инструкция, т.е. может возвращать значения. На псевдокоде Rust это будет выглядеть так:


(mod "./index.js")
    .await
    .map_err(|e| {
        eprintln!("Error importing `index.js`:", e);
    });

Мы видим, что этот код загружает index.js, поэтому заглянем туда:


import * as wasm from "hello-wasm-pack";
//                     ^-------------^
//                      определяется в package.json

wasm.greet();

Этот import больше напоминает extern crate:


extern crate hello_wasm_pack as wasm;

wasm::greet();

Если говорить о коде приложения, то это все.


В директории www имеется еще один интересный файл — webpack.config.js:


const CopyWebpackPlugin = require("copy-webpack-plugin");
const path = require('path');

module.exports = {
  entry: "./bootstrap.js",
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "bootstrap.js",
  },
  mode: "development",
  plugins: [
    new CopyWebpackPlugin(['index.html'])
  ],
};

Этот файл содержит настройки для webpack, который похож на Cargo для JS.


Давайте кое-что проясним:
  • npm управляет зависимостями
  • webpack собирает приложение


В Rust все это делает Cargo (как удобно!), но в JS для этого нужны отдельные инструменты.

Мы узнали своего врага, что дальше?


Обменявшись любезностями с файловой системой, вернемся к делу:


  1. На данный момент npm не знает о lib-simulation-wasm, исправим это:

// www/package.json
{
  /* ... */
  "devDependencies": {
    "lib-simulation-wasm": "file:../libs/simulation-wasm/pkg",
    /* ... */
  }
}

  1. Сообщаем npm об этом изменении:

cd www
npm install

  1. Пришло время index.js:

import * as sim from "lib-simulation-wasm";

alert("Who's that dog? " + sim.whos_that_dog() + "!");

Здесь нет function main() { }, потому что в JS она не нужна, весь код выполняется сверху вниз (более или менее).

  1. Запускаем приложение (по сути, это cargo run):

npm run start

...
ℹ 「wds」: Project is running at http://localhost:8080/
...

Если выполнение этой команды проваливается с error:0308010C, возможно, вы используете слишком новую версию Node.js. Попробуйте запустить команду так:

NODE_OPTIONS=--openssl-legacy-provider npm run start

Открываем http://localhost:8080/ в браузере:





Ура!


Сервер, запущенный с помощью npm run start, автоматически реагирует на изменения.

Если вы хотите изменить сообщение, вернитесь в lib.rs, сделайте, что хотите, запустите wasm-pack build, и через несколько секунд сайт должен автоматически перезагрузиться.

То же самое относится к HTML и JS, хотя в этом случае повторно запускать wasm-pack не нужно.

Привет, WA (заключение)!


Итак… Что это все значит?


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


В следующем разделе мы начнем заниматься симуляцией.


Привет, симуляция!


Как обычно, начинаем с создания нового крейта:


cd ../libs
cargo new simulation --lib --name lib-simulation

Этот крейт будет содержать движок нашей симуляции:


pub struct Simulation;

Чтобы не создавать дизайн из воздуха, вспомним предыдущий рисунок:





Что мы здесь видим? Конечно, мы видим здесь мир:


pub struct Simulation {
    world: World,
}

#[derive(Debug)]
pub struct World;

… который содержит животных (птиц!) и еду (богатую белками и клетчаткой!):


/* ... */

#[derive(Debug)]
pub struct World {
    animals: Vec<Animal>,
    foods: Vec<Food>,
}

#[derive(Debug)]
pub struct Animal;

#[derive(Debug)]
pub struct Food;

… которые находятся в некоторых координатах:


/* ... */

#[derive(Debug)]
pub struct Animal {
    position: ?,
}

#[derive(Debug)]
pub struct Food {
    position: ?,
}

Наш мир является двумерным, что приводит нас к:


/* ... */

#[derive(Debug)]
pub struct Animal {
    position: Point2,
}

#[derive(Debug)]
pub struct Food {
    position: Point2,
}

#[derive(Debug)]
pub struct Point2 {
    x: f32,
    y: f32,
}

Кроме того, животные имеют определенный угол поворота...


До сих пор почти весь код мы писали вручную.


Сейчас, когда дело дошло до некоторых математических структур данных, мне бы не хотелось изобретать велосипед — отчасти потому, что ручное написание кода почти ничему нас не научит:


#[derive(Copy, Clone, Debug)]
pub struct Point2 {
    x: f32,
    y: f32,
}

impl Point2 {
    pub fn new(...) -> Self {
        /* ... */
    }

    /* ... */
}

impl Add<Point2> for Point2 {
    /* ... */
}

impl Sub<Point2> for Point2 {
    /* ... */
}

impl Mul<Point2> for f32 {
    /* ... */
}

impl Mul<f32> for Point2 {
    /* ... */
}

#[cfg(test)]
mod tests {
    /* ... */
}

… и отчасти потому, что я хочу познакомить вас с крейтом, который мне очень нравится: nalgebra!


Цитируя их документацию:


nalgebra — это библиотека линейной алгебры, написанная для Rust:
  • линейная алгебра общего назначения (все еще не хватает многих функций...)
  • компьютерная графика в реальном времени
  • компьютерная физика в реальном времени

Другими словами, nalgebra — это математика для людей, сделанная правильно, хорошо работающая с WA.


nalgebra предоставляет множество инструментов: от простых функций, таких как clamp, до довольно сложных структур, таких как кватернионы, и до нашей любимой точки.


Редактируем манифест:


# libs/simulation/Cargo.toml
# ...

[dependencies]
nalgebra = "0.26"

… и затем:


use nalgebra as na;
// --------- ^^
// | Такой вид импорта называется псевдонимом (alias).
// | Псевдонимом `nalgebra` является `na`.
// ---

/* ... */

#[derive(Debug)]
pub struct Animal {
    position: na::Point2<f32>,
}

#[derive(Debug)]
pub struct Food {
    position: na::Point2<f32>,
}

На чем мы остановились? Ах да, животные имеют определенный угол вращения и скорость:


/* ... */

#[derive(Debug)]
pub struct Animal {
    position: na::Point2<f32>,
    rotation: na::Rotation2<f32>,
    speed: f32,
}

/* ... */

В целом вращение и скорость также можно представить вместе в виде вектора:

#[derive(Debug)]
pub struct Animal {
    position: na::Point2<f32>,
    velocity: na::Vector2<f32>,
}

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

Теперь, когда у нас есть несколько моделей, было бы неплохо их как-нибудь сконструировать, поэтому:


# ...

[dependencies]
nalgebra = "0.26"
rand = "0.8"

… и пока мы здесь, включим поддержку rand в nalgebra — она нам пригодится через мгновение:


# ...

[dependencies]
nalgebra = { version = "0.26", features = ["rand-no-std"] }
rand = "0.8"

Начнем с нескольких элементарных конструкторов, которые просто все рандомизируют:


use nalgebra as na;
use rand::{Rng, RngCore};

/* ... */

impl Simulation {
    pub fn random(rng: &mut dyn RngCore) -> Self {
        Self {
            world: World::random(rng),
        }
    }
}

impl World {
    pub fn random(rng: &mut dyn RngCore) -> Self {
        let animals = (0..40)
            .map(|_| Animal::random(rng))
            .collect();

        let foods = (0..60)
            .map(|_| Food::random(rng))
            .collect();

        // ^ Наш алгоритм позволяет животным и еде накладываться друг на друга,
        // | это не идеально, но для наших целей сойдет.
        // |
        // | Более сложное решение может быть основано, например, на
        // | избыточной выборке сглаживания:
        // |
        // | https://en.wikipedia.org/wiki/Supersampling
        // ---

        Self { animals, foods }
    }
}

impl Animal {
    pub fn random(rng: &mut dyn RngCore) -> Self {
        Self {
            position: rng.gen(),
            // ------ ^-------^
            // | Если бы не `rand-no-std`, нам пришлось бы делать
            // | `na::Point2::new(rng.gen(), rng.gen())`
            // ---

            rotation: rng.gen(),
            speed: 0.002,
        }
    }
}

impl Food {
    pub fn random(rng: &mut dyn RngCore) -> Self {
        Self {
            position: rng.gen(),
        }
    }
}

Геттер — это функция для доступа к состоянию объекта, реализуем парочку:


/* ... */

impl Simulation {
    /* ... */

    pub fn world(&self) -> &World {
        &self.world
    }
}

impl World {
    /* ... */

    pub fn animals(&self) -> &[Animal] {
        &self.animals
    }

    pub fn foods(&self) -> &[Food] {
        &self.foods
    }
}

impl Animal {
    /* ... */

    pub fn position(&self) -> na::Point2<f32> {
        // ------------------ ^
        // | Нет необходимости возвращать ссылку, поскольку `na::Point2` является копируемым (реализует типаж `Copy`).
        // |
        // | (он настолько маленький, что клонирование дешевле, чем возня с ссылками)
        // ---

        self.position
    }

    pub fn rotation(&self) -> na::Rotation2<f32> {
        self.rotation
    }
}

impl Food {
    /* ... */

    pub fn position(&self) -> na::Point2<f32> {
        self.position
    }
}

  • Мир? Есть.
  • Животные? Есть.
  • Еда? Есть.

Отлично.


Еще не хватает большого количества кода, но на данный момент его достаточно, чтобы отобразить что-нибудь на экране с помощью JS.


JS


Теперь вы можете спросить: "Если мы хотим вызывать это из JS, разве мы не должны везде использовать #[wasm_bindgen]?"


… на что я отвечу: "Отличный вопрос!"


Я думаю, важно помнить о разделении задач: в lib-simulation основное внимание должно уделяться тому, "как моделировать эволюцию", а не "как моделировать эволюцию и интегрироваться с WA".


Через секунду мы реализуем lib-simulation-wasm, и если мы оставим lib-simulation независимым от фронтенда, будет легко создать, скажем, lib-simulation-bevy или lib-simulation-cli — все они будут использовать общий код симуляции под капотом.


Хорошо, вернемся к lib-simulation-wasm. Нам нужно сообщить ему о rand и lib-simulation:


# libs/simulation-wasm/Cargo.toml

[dependencies]
rand = "0.8"
wasm-bindgen = "0.2"

lib-simulation = { path = "../simulation" }
                        # ^ путь является относительным к *этому* Cargo.toml

Теперь внутри lib-simulation-wasm мы можем обратиться к lib_simulation:


use lib_simulation as sim;

… и реализовать обертку для WA (это называется прокси):


use lib_simulation as sim;
use rand::prelude::*;
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub struct Simulation {
    rng: ThreadRng,
    sim: sim::Simulation,
}

#[wasm_bindgen]
impl Simulation {
    #[wasm_bindgen(constructor)]
    pub fn new() -> Self {
        let mut rng = thread_rng();
        let sim = sim::Simulation::random(&mut rng);

        Self { rng, sim }
    }
}

Выглядит неплохо, попробуем скомпилировать этот код.


wasm-pack build

INFO]: Checking for the Wasm target...
[INFO]: Compiling to Wasm...

   Compiling getrandom v0.2.2

   error: target is not supported, for more information see:
          https://docs.rs/getrandom/#unsupported-targets
--> /home/pwy/.cargo/registry/src/...
3:9
    |
213 | /         compile_error!("target is not supported, for more information see: \\
214 | |                         https://docs.rs/getrandom/#unsupported-targets");
    | |_________________________________________________________________________^

error[E0433]: failed to resolve: use of undeclared crate or module `imp`
   --> /home/pwy/.cargo/registry/src/...
5:5
    |
235 |     imp::getrandom_inner(dest)
    |     ^^^ use of undeclared crate or module `imp`

Честно говоря, эта ошибка застала меня врасплох. К счастью, связанная страница довольно хорошо описывает проблему: rand зависит от getrandom, который поддерживает WA для браузера, но только когда его явно об этом просят:


# libs/simulation-wasm/Cargo.toml

[dependencies]
# ...
getrandom = { version = "0.2", features = ["js"] }

Компилируемся:


[INFO]: Checking for the Wasm target...
[INFO]: Compiling to Wasm...

warning: field is never read: `rng`
    ...

warning: field is never read: `sim`
    ...

warning: 2 warnings emitted

    Finished release [optimized] target(s) in 0.01s

[WARN]: origin crate has no README
[INFO]: Installing wasm-bindgen...
[INFO]: Optimizing wasm binaries with `wasm-opt`...
[INFO]: Optional fields missing from Cargo.toml: 'description',
        'repository', and 'license'. These are not necessary, but
         recommended
[INFO]: :-) Done in 0.63s
[INFO]: :-) Your wasm pkg is ready to publish at /home/pwy/Projects/...

На стороне JS мы теперь можем сделать следующее:


// www/index.js
import * as sim from "lib-simulation-wasm";

const simulation = new sim.Simulation();

Отлично, у нас есть готовый движок симуляции!


Однако это довольно тихий движок, потому что в настоящее время он просто рандомизирует некоторых животных и еду и замолкает. Чтобы было интереснее, передадим больше данных в JS.


Для этого нам понадобится еще несколько моделей, они будут как бы скопированы из lib-simulation, но с учетом WA:


// libs/simulation-wasm/src/lib.rs
/* ... */

#[wasm_bindgen]
pub struct Simulation {
    /* ... */
}

#[wasm_bindgen]
#[derive(Clone, Debug)]
pub struct World {
    pub animals: Vec<Animal>,
}

#[wasm_bindgen]
#[derive(Clone, Debug)]
pub struct Animal {
    pub x: f32,
    pub y: f32,
}
// ^ Эта модель меньше `lib_simulation::Animal`, поскольку
// | позиция птицы - это все, что нам нужно на данный момент
// | на стороне JS, другие поля нам пока не нужны.

/* ... */

… и 2 метода преобразования:


/* ... */

#[wasm_bindgen]
impl Simulation {
    /* ... */
}

/* ... */

impl From<&sim::World> for World {
    fn from(world: &sim::World) -> Self {
        let animals = world.animals().iter().map(Animal::from).collect();

        Self { animals }
    }
}

impl From<&sim::Animal> for Animal {
    fn from(animal: &sim::Animal) -> Self {
        Self {
            x: animal.position().x,
            y: animal.position().y,
        }
    }
}

Теперь мы можем добавить:


/* ... */

#[wasm_bindgen]
impl Simulation {
    /* ... */

    pub fn world(&self) -> World {
        World::from(self.sim.world())
    }
}

/* ... */

Выполняем wasm-pack build и...


error[E0277]: the trait bound `Vec<Animal>: std::marker::Copy` is not satisfied
  --> libs/simulation-wasm/src/lib.rs
   |
   |     pub animals: Vec<Animal>,
   |                            ^ the trait `std::marker::Copy` is not ...
   |

Это произошло, потому что #[wasm_bindgen] автоматически создает геттеры и сеттеры для JS, но требует, чтобы эти поля были Copy. Макрос делает нечто похожее на:


impl World {
    pub fn animals(&self) -> Vec<Animal> {
        self.animals
    }
}

… что тоже не будет работать без явного .clone() — это именно то, что нам нужно сообщить #[wasm_bindgen]:


/* ... */

#[wasm_bindgen]
#[derive(Clone, Debug)]
pub struct World {
    #[wasm_bindgen(getter_with_clone)]
    pub animals: Vec<Animal>,
}

/* ... */

// www/index.js
import * as sim from "lib-simulation-wasm";

const simulation = new sim.Simulation();
const world = simulation.world();

Все это должно работать...​ в теории, но как убедиться, что наш мир содержит какие-либо значимые данные? Используя наши собственные глаза!


Большинство браузеров предоставляют инструменты разработчика, доступ к которым можно получить, нажав F12.


Что мы можем сделать? Мы можем напечатать что-нибудь в консоли:


/* ... */

for (const animal of world.animals) {
  console.log(animal.x, animal.y);
}

Теперь, когда мы знаем положение животных, мы можем их нарисовать!


Привет, графика!


Рисовать в HTML + JS относительно легко — мы будем использовать вещь под названием canvas (холст):


<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Hello wasm-pack!</title>
  </head>

  <body>
    <canvas id="viewport"></canvas>
    <script src="./bootstrap.js"></script>
  </body>
</html>

У нашего холста есть атрибут id — этот атрибут используется для идентификации тегов, чтобы их можно было легко найти в JS:


/* ... */

const viewport = document.getElementById('viewport');
// ------------- ^------^
// | `document` - это глобальный объект, предоставляющий доступ и позволяющий модифицировать
// | текущую страницу (например, создавать и удалять элементы на ней).
// ---

Если вы хотите воспользоваться моментом, чтобы переварить происходящее, смело используйте console.log(viewport) и изучите свойства холста — их очень много!


Чтобы отобразить что-либо на canvas, нам нужно запросить определенный режим рисования (2D или 3D):


/* ... */

const ctxt = viewport.getContext('2d');

Для справки, наш ctxt имеет тип CanvasRenderingContext2D.

Существует большое количество методов и свойств, которые мы можем вызвать на ctxt. Начнем с fillStyle и fillRect():


/* ... */

// ---
// | Определяет цвет фигуры.
// - v-------v
ctxt.fillStyle = 'rgb(255, 0, 0)';
// ------------------ ^-^ -^ -^
// | Каждый из трех параметров - число от 0 до 255, включительно:
// |
// | rgb(0, 0, 0) = black
// |
// | rgb(255, 0, 0) = red
// | rgb(0, 255, 0) = green
// | rgb(0, 0, 255) = blue
// |
// | rgb(255, 255, 0) = yellow
// | rgb(0, 255, 255) = cyan
// | rgb(255, 0, 255) = magenta
// |
// | rgb(128, 128, 128) = gray
// | rgb(255, 255, 255) = white
// ---

ctxt.fillRect(10, 10, 100, 50);
// ---------- X   Y   W    H
// | Рисует прямоугольник, заполненный цветом, определенным с помощью `fillStyle`.
// |
// | X = координата по оси X (слева направо)
// | Y = координата по оси Y (сверху вниз)
// | W = ширина
// | X = высота
// |
// | (единица измерения - пиксель)
// ---

Запуск этого кода заставляет наш canvas нарисовать красный прямоугольник:





Ах, Мондриан был бы так горд — говорю вам: мы двигаемся в правильном направлении!


Система координат canvas:




Это мои данные


Теперь, когда мы знаем, как рисовать прямоугольники, используем наши данные:


import * as sim from "lib-simulation-wasm";

const simulation = new sim.Simulation();
const viewport = document.getElementById('viewport');
const ctxt = viewport.getContext('2d');

ctxt.fillStyle = 'rgb(0, 0, 0)';

for (const animal of simulation.world().animals) {
    ctxt.fillRect(animal.x, animal.y, 15, 15);
}

… и:





Что случилось? Изучим наши данные еще раз:


/* ... */

for (const animal of simulation.world().animals) {
  console.log(animal.x, animal.y);
}

0.6751065850257874 0.9448947906494141
0.2537931203842163 0.4474523663520813
0.7111597061157227 0.731094241142273
0.20178401470184326 0.5820554494857788
0.7062546610832214 0.3024316430091858
0.030273854732513428 0.4638679623603821
0.48392945528030396 0.9207395315170288
0.49439138174057007 0.24340438842773438
0.5087683200836182 0.10066533088684082
/* ... */

Позиции наших животных принадлежат диапазону <0.0, 1.0>, а canvas ожидает координаты в пикселях — мы можем это исправить, масштабируя числа во время рендеринга:


/* ... */

const viewport = document.getElementById('viewport');
const viewportWidth = viewport.width;
const viewportHeight = viewport.height;

/* ... */

for (const animal of simulation.world().animals) {
    ctxt.fillRect(
        animal.x * viewportWidth,
        animal.y * viewportHeight,
        15,
        15,
    );
}

… что дает нам:





Если вы являетесь счастливым обладателем дисплея HiDPI, то можете заметить, что холст выглядит немного размытым — не трогайте монитор, все "правильно".

Это связано с тем, что большинство, если не все, браузеры отображают холсты без корректировки плотности пикселей экрана, а затем искусственно повышают масштаб изображения, чтобы оно соответствовало фактическому разрешению экрана.

Преодоление этого "неудобства" требует некоторой хитрости, которая сводится к ручному увеличению размеров холста перед рисованием, чтобы "исправить" поведение браузера по умолчанию:

/* ... */

const viewportWidth = viewport.width;
const viewportHeight = viewport.height;

const viewportScale = window.devicePixelRatio || 1;
// ------------------------------------------ ^^^^
// | Это похоже на `.unwrap_or(1)`
// |
// | Это значение определяет количество физических пикселей
// | в одном пикселе на холсте.
// |
// | Не HiDPI дисплеи обычно имеют плотность пикселей, равную 1.0.
// | Это означает, что рисование одного пикселя на холсте раскрасит
// | ровно один физический пиксель на экране.
// |
// | Мой дисплей имеет плотность пикселей, равную 2.0.
// | Это означает, что каждому пикселю, нарисованному на холсте,
// | будет соответствовать два физических пикселя, модифицированных браузером.
// ---

// Трюк, часть 1: мы увеличиваем *буфер* холста, чтобы он
// совпадал с плотностью пикселей экрана
viewport.width = viewportWidth * viewportScale;
viewport.height = viewportHeight * viewportScale;

// Трюк, часть 2: мы уменьшаем *элемент* холста, поскольку
// браузер автоматически умножит его на плотность пикселей через мгновение.
//
// Это может показаться бесполезным, но суть заключается в том,
// что модификация размера элемента холста не влияет на
// размер его буфера, который *остается* увеличенным:
//
// ----------- < наша страница
// |         |
// |   ---   |
// |   | | < | < наш холст
// |   ---   |   (размер: viewport.style.width & viewport.style.height)
// |         |
// -----------
//
// За пределами страницы, в памяти браузера:
//
// ----- < буфер нашего холста
// |   | (размер: viewport.width & viewport.height)
// |   |
// -----
viewport.style.width = viewportWidth + 'px';
viewport.style.height = viewportHeight + 'px';

const ctxt = viewport.getContext('2d');

// Автоматически масштабирует все операции на `viewportScale`, иначе
// нам пришлось бы `* viewportScale` все вручную
ctxt.scale(viewportScale, viewportScale);

// Остальной код без изменений
ctxt.fillStyle = 'rgb(0, 0, 0)';

for (const animal of simulation.world().animals) {
    ctxt.fillRect(
        animal.x * viewportWidth,
        animal.y * viewportHeight,
        15,
        15,
    );
}

Это дает нам четкое изображение:




Верите или нет, но кто-то сказал мне, что квадратное уже не в моде!


Судя по всему, сейчас в моде ▼ треугольники ▲, попробуем нарисовать один.


К сожалению, JS не предоставляет метода для рисования треугольников, поэтому нам придется рисовать их вручную, вершина за вершиной.


Чтобы понять, как это работает, начнем с жестко закодированного примера:


/* ... */

// Начинаем рисовать многоугольник
ctxt.beginPath();

// Передвигаем курсор на x=50, y=0
ctxt.moveTo(50, 0);

// Рисуем линию от (50,0) до (100,50) и передвигаем курсор туда
// (это рисует правую сторону нашего треугольника)
ctxt.lineTo(100, 50);

// Рисуем линию от (100,50) до (0,50) и передвигаем курсор туда
// (это рисует нижнюю сторону)
ctxt.lineTo(0, 50);

// Рисуем линию от (0,50) до (50,0) и передвигаем курсор туда
// (это рисует левую сторону)
ctxt.lineTo(50, 0);

// Заливаем треугольник черным цветом
// (также существует `ctxt.stroke();`, который нарисует
// треугольник без заливки).
ctxt.fillStyle = 'rgb(0, 0, 0)';
ctxt.fill();

Какая красота!


Поскольку рисование треугольника требует нескольких шагов, создадим вспомогательную функцию:


/* ... */

function drawTriangle(ctxt, x, y, size) {
    ctxt.beginPath();
    ctxt.moveTo(x, y);
    ctxt.lineTo(x + size, y + size);
    ctxt.lineTo(x - size, y + size);
    ctxt.lineTo(x, y);

    ctxt.fillStyle = 'rgb(0, 0, 0)';
    ctxt.fill();
}

drawTriangle(ctxt, 50, 0, 50);

… или если быть более идиоматичным:


/* ... */

// ---
// | Тип (точнее, прототип) нашего `ctxt`.
// v------------------ v
CanvasRenderingContext2D.prototype.drawTriangle =
    function (x, y, size) {
        this.beginPath();
        this.moveTo(x, y);
        this.lineTo(x + size, y + size);
        this.lineTo(x - size, y + size);
        this.lineTo(x, y);

        this.fillStyle = 'rgb(0, 0, 0)';
        this.fill();
    };

ctxt.drawTriangle(50, 0, 50);

Прим. пер.: модификация прототипа объекта является антипаттерном в JS.


JS позволяет создавать методы во время выполнения, аналогичный код на Rust потребовал бы создания типажа:

trait DrawTriangle {
    fn draw_triangle(&mut self, x: f32, y: f32, size: f32);
}

impl DrawTriangle for CanvasRenderingContext2D {
    fn draw_triangle(&mut self, x: f32, y: f32, size: f32) {
        self.begin_path();
        /* ... */
    }
}

Теперь мы можем сделать так:


<!-- ... -->
<canvas id="viewport" width="800" height="800"></canvas>
<!-- ... -->

/* ... */

for (const animal of simulation.world().animals) {
    ctxt.drawTriangle(
        animal.x * viewportWidth,
        animal.y * viewportHeight,
        0.01 * viewportWidth,
    );
}




Если эти треугольники кажутся вам слишком маленькими, смело регулируйте размер холста и параметр 0.01.


Отлично ?


… и знаете, что было бы еще лучше? Если бы треугольники были повернутыми!


Вершины


У нас уже есть поле rotation внутри lib-simulation:


/* ... */

#[derive(Debug)]
pub struct Animal {
    /* ... */
    rotation: na::Rotation2<f32>,
    /* ... */
}

… поэтому все, что нам нужно сделать, это передать его в JS:


/* ... */

#[wasm_bindgen]
#[derive(Clone, Debug)]
pub struct Animal {
    pub x: f32,
    pub y: f32,
    pub rotation: f32,
}

impl From<&sim::Animal> for Animal {
    fn from(animal: &sim::Animal) -> Self {
        Self {
            x: animal.position().x,
            y: animal.position().y,
            rotation: animal.rotation().angle(),
        }
    }
}

/* ... */

Выполняем wasm-pack build и возвращаемся к JS.


Поскольку вращение будет разным для каждого треугольника, нам понадобится еще один параметр:


/* ... */

CanvasRenderingContext2D.prototype.drawTriangle =
    function (x, y, size, rotation) {
        /* ... */
    };

/* ... */

Интуитивно мы ищем следующее:





… но обобщенное для любого угла.


Вершины (математика)


Вернемся к нашему треугольнику вместе с окружностью:





В данном случае я бы описал вращение как перемещение каждой вершины вдоль круга "под" определенным углом:





Как узнать, куда перемещать эти точки? Что ж, когда речь идет о круге, как правило, прибегают к тригонометрии.


Возможно, вы слышали о cos() и sin():





При их применении к кругу, можно заметить, что cos(angle) возвращает координату y точки, "повернутой" на определенный угол, а sin(angle) возвращает координату x:





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

На самом деле мы собираемся использовать x = -sin(angle), потому что nalgebra понимает вращение против часовой стрелки.

Если то, что у нас есть на данный момент:


this.moveTo(x, y);

… значит, вращение координаты x требует применения - sin():


this.moveTo(
    x - Math.sin(rotation) * size,
    y,
);

… а вращение координаты y требует применения + cos():


this.moveTo(
    x - Math.sin(rotation) * size,
    y + Math.cos(rotation) * size,
);

… где rotation измеряется в радианах, поэтому <0°, 360°> становится <0, 2 * PI>:


  • 0° ⇒ rotation = 0
  • 180° ⇒ rotation = PI
  • 360° ⇒ rotation = 2 * PI
  • 90° ⇒ 180° / 2 ⇒ rotation = PI / 2
  • 45° ⇒ 180° / 4 ⇒ rotation = PI / 4
  • и т.д.

Отлично, с одной вершиной разобрались:





… осталось еще две.


Поскольку весь круг занимает 360°, а нам нужно нарисовать три вершины, то каждая вершина будет занимать 360°/3 = 120°; учитывая, что первая вершина лежит на 0°, вторая вершина будет расположена на 120°.


Быстрый перевод в радианы с использованием пропорций:


{ 2 * PI = 360°
{      x = 120°

         ^
         |
         v

360° * x = 2 * PI * 120°    | делить на 2
180° * x = PI * 120°        | делить на 180°
       x = PI * 120° / 180° | упростить
       x = PI * 2 / 3       | переместить константу
       x = 2 / 3 * PI       | наслаждаться

… что дает нам:


this.moveTo(
    x - Math.sin(rotation) * size,
    y + Math.cos(rotation) * size,
);

this.lineTo(
    x - Math.sin(rotation + 2.0 / 3.0 * Math.PI) * size,
    y + Math.cos(rotation + 2.0 / 3.0 * Math.PI) * size,
);




Для третьей вершины:


2 * PI = 360°
     x = 120° + 120°

/* ... */

x = 4 / 3 * PI

… что дает нам:


this.moveTo(
    x - Math.sin(rotation) * size,
    y + Math.cos(rotation) * size,
);

this.lineTo(
    x - Math.sin(rotation + 2.0 / 3.0 * Math.PI) * size,
    y + Math.cos(rotation + 2.0 / 3.0 * Math.PI) * size,
);

this.lineTo(
    x - Math.sin(rotation + 4.0 / 3.0 * Math.PI) * size,
    y + Math.cos(rotation + 4.0 / 3.0 * Math.PI) * size,
);




Вместо + 4.0 / 3.0, мы можем использовать - 2.0 / 3.0 (60° против часовой стрелки от верхней вершины):

this.lineTo(
    x - Math.sin(rotation - 2.0 / 3.0 * Math.PI) * size,
    y + Math.cos(rotation - 2.0 / 3.0 * Math.PI) * size,
);

… результат будет одинаковым.

Нам не хватает последнего ребра, идущего из третьей вершины обратно в первую:





… поэтому:


/* ... */

this.lineTo(
    x - Math.sin(rotation + 4.0 / 3.0 * Math.PI) * size,
    y + Math.cos(rotation + 4.0 / 3.0 * Math.PI) * size,
);

this.lineTo(
    x - Math.sin(rotation) * size,
    y + Math.cos(rotation) * size,
);

Полный код:


/* ... */

CanvasRenderingContext2D.prototype.drawTriangle =
    function (x, y, size, rotation) {
        this.beginPath();

        this.moveTo(
            x - Math.sin(rotation) * size,
            y + Math.cos(rotation) * size,
        );

        this.lineTo(
            x - Math.sin(rotation + 2.0 / 3.0 * Math.PI) * size,
            y + Math.cos(rotation + 2.0 / 3.0 * Math.PI) * size,
        );

        this.lineTo(
            x - Math.sin(rotation + 4.0 / 3.0 * Math.PI) * size,
            y + Math.cos(rotation + 4.0 / 3.0 * Math.PI) * size,
        );

        this.lineTo(
            x - Math.sin(rotation) * size,
            y + Math.cos(rotation) * size,
        );

        this.stroke();
    };

ctxt.drawTriangle(50, 50, 25, Math.PI / 4);

Это работает?





… но сложно заметить, что треугольник повернут. Что, если мы вытянем одну из вершин?


/* ... */

CanvasRenderingContext2D.prototype.drawTriangle =
    function (x, y, size, rotation) {
        this.beginPath();

        this.moveTo(
            x - Math.sin(rotation) * size * 1.5,
            y + Math.cos(rotation) * size * 1.5,
        );

        /* ... */

        this.lineTo(
            x - Math.sin(rotation) * size * 1.5,
            y + Math.cos(rotation) * size * 1.5,
        );

        this.stroke();
    };

/* ... */

Так лучше:





Животные


Теперь мы можем модифицировать наш код:


/* ... */

for (const animal of simulation.world().animals) {
    ctxt.drawTriangle(
        animal.x * viewportWidth,
        animal.y * viewportHeight,
        0.01 * viewportWidth,
        animal.rotation,
    );
}

… что дает нам:





Замечательно!


Разработка: step()


Стационарные треугольники — это круто, но движущиеся треугольники круче.


У наших птиц нет мозга, но у них есть вращение и скорость. Это означает, что мы можем применять к ним физику, даже если они пока не могут взаимодействовать с окружающей средой:


/* ... */

impl Simulation {
    /* ... */

    /// Выполняет один шаг - одну секунду нашей симуляции, так сказать.
    pub fn step(&mut self) {
        for animal in &mut self.world.animals {
            animal.position += animal.rotation * animal.speed;
        }
    }
}

/* ... */

Выполняем cargo check:


error[E0277]: cannot multiply `Rotation<f32, 2_usize>` by `f32`
   |
   |             animal.position += animal.rotation * animal.speed;
   |                                                ^
   |             -----------------------------------|
   |             no implementation for `Rotation<f32, 2_usize> * f32`
   |
   = help: the trait `Mul<f32>` is not implemented for
     `Rotation<f32, 2_usize>`

Хм, nalgebra не поддерживает Rotation2 * f32. Возможно, мы можем использовать вектор?


animal.position += animal.rotation * na::Vector2::new(0.0, animal.speed);

cargo check

Finished dev [unoptimized + debuginfo] target(s) in 18.04s

Бинго!


Почему ::new(0.0, animal.speed)?

nalgebra не имеет точки отсчета, инструкция пожалуйста, переместись на 45° не содержит достаточно информации для вычисления того, куда должна переместиться птичка: 45° по какой оси?




Эту проблему решает наш ::new(0.0, animal.speed) — нас интересует вращение относительно оси Y, т.е. птица с поворотом 0° будет лететь вверх.

В общем, это довольно произвольное решение, которое полностью соответствует тому, как мы визуализируем треугольники на холсте; мы могли бы сделать, например, ::new(-animal.speed, 0.0) и настроить функцию drawTriangle() соответствующим образом.

Имея функцию step() внутри lib-simulation, мы можем предоставить ее через lib-simulation-wasm:


/* ... */

#[wasm_bindgen]
impl Simulation {
    /* ... */

    pub fn step(&mut self) {
        self.sim.step();
    }
}

/* ... */

Компилируем код:


wasm-pack build

....
[INFO]: :-) Done in 3.58s
[INFO]: :-) Your wasm pkg is ready to publish at /home/pwy/Projects/...

Что касается вызова .step() из JS, хотя в некоторых языках мы могли бы использовать цикл:


/* ... */

while (true) {
     ctxt.clearRect(0, 0, viewportWidth, viewportHeight);

     simulation.step();

     for (const animal of simulation.world().animals) {
         ctxt.drawTriangle(
             animal.x * viewportWidth,
             animal.y * viewportHeight,
             0.01 * viewportWidth,
             animal.rotation,
         );
     }
}

… среда веб-браузера немного усложняет задачу, поскольку наш код не должен быть блокирующим. Это связано с тем, что когда JS выполняется, браузер ждет его завершения, замораживая вкладку и не позволяя пользователю взаимодействовать со страницей.


Чем больше времени требуется для выполнения кода, тем дольше блокируется вкладка — по сути, она однопоточная. Итак, если мы напишем while (true) { ...​ }, браузер заблокирует вкладку навсегда, терпеливо ожидая, пока код завершит работу.


Вместо блокировки, можно использовать функцию под названием requestAnimationFrame() — она планирует выполнение функции непосредственно перед отрисовкой следующего кадра, а сама завершается немедленно:


/* ... */

function redraw() {
    ctxt.clearRect(0, 0, viewportWidth, viewportHeight);

    simulation.step();

    for (const animal of simulation.world().animals) {
        ctxt.drawTriangle(
            animal.x * viewportWidth,
            animal.y * viewportHeight,
            0.01 * viewportWidth,
            animal.rotation,
        );
    }

    // requestAnimationFrame() планирует выполнение кода перед отрисовкой следующего кадра.
    //
    // Поскольку мы хотим, чтобы наша симуляция выполнялась вечно,
    // функцию необходимо зациклить
    requestAnimationFrame(redraw);
}

redraw();

Вуаля:



… эм, почему птицы через некоторое время исчезают?


Вернемся к lib-simulation:


/* ... */

impl Simulation {
    /* ... */

    pub fn step(&mut self) {
        for animal in &mut self.world.animals {
            animal.position += animal.rotation * na::Vector2::new(0.0, animal.speed);
        }
    }
}

/* ... */

Итак, мы добавляем вращение к положению и… ах, да! Наша карта ограничена координатами <0.0, 1.0> — все, что находится за пределами этих координат, отображается за пределами холста.


Исправим это:


/* ... */

impl Simulation {
    /* ... */

    pub fn step(&mut self) {
        for animal in &mut self.world.animals {
            animal.position += animal.rotation * na::Vector2::new(0.0, animal.speed);

            animal.position.x = na::wrap(animal.position.x, 0.0, 1.0);
            animal.position.y = na::wrap(animal.position.y, 0.0, 1.0);
        }
    }
}

/* ... */

wrap() делает следующее: первый аргумент — это проверяемое число, а второй и третий аргументы определяют минимально и максимально допустимые значения:


  • na::wrap(0.5, 0.0, 1.0) == 0.5 (числа между [min,max] остаются нетронутыми)
  • na::wrap(-0.5, 0.0, 1.0) == 1.0 (if число < min { return max; })
  • na::wrap(1.5, 0.0, 1.0) == 0.0 (if число > max { return min; })

wasm-pack build


Отлично!


Ты не ты (когда голоден)


Птицы составляют лишь половину нашей экосистемы, у нас еще есть еда.


Рендеринг еды


Поскольку мы уже написали много кода, рендеринг еды сводится к нескольким изменениям:


/* ... */

#[wasm_bindgen]
#[derive(Clone, Debug)]
pub struct World {
    #[wasm_bindgen(getter_with_clone)]
    pub animals: Vec<Animal>,

    #[wasm_bindgen(getter_with_clone)]
    pub foods: Vec<Food>,
}

impl From<&sim::World> for World {
    fn from(world: &sim::World) -> Self {
        let animals = world.animals().iter().map(Animal::from).collect();
        let foods = world.foods().iter().map(Food::from).collect();

        Self { animals, foods }
    }
}

/* ... */

#[wasm_bindgen]
#[derive(Clone, Debug)]
pub struct Food {
    pub x: f32,
    pub y: f32,
}

impl From<&sim::Food> for Food {
    fn from(food: &sim::Food) -> Self {
        Self {
            x: food.position().x,
            y: food.position().y,
        }
    }
}

/* ... */

CanvasRenderingContext2D.prototype.drawTriangle =
    function (x, y, size, rotation) {
        /* ... */
    };

CanvasRenderingContext2D.prototype.drawCircle =
    function(x, y, radius) {
        this.beginPath();

        // ---
        // | Центр круга.
        // ----- v -v
        this.arc(x, y, radius, 0, 2.0 * Math.PI);
        // ------------------- ^ -^-----------^
        // | Начало и конец окружности, в радианах.
        // |
        // | Меняя эти параметры можно, например, нарисовать
        // | только половину круга.
        // ---

        this.fillStyle = 'rgb(0, 0, 0)';
        this.fill();
    };

function redraw() {
    ctxt.clearRect(0, 0, viewportWidth, viewportHeight);

    simulation.step();

    const world = simulation.world();

    for (const food of world.foods) {
        ctxt.drawCircle(
            food.x * viewportWidth,
            food.y * viewportHeight,
            (0.01 / 2.0) * viewportWidth,
        );
    }

    for (const animal of world.animals) {
        /* ... */
    }

    /* ... */
}

/* ... */

Верите или нет, но этого достаточно!


wasm-pack build

....
[INFO]: :-) Done in 1.25s
[INFO]: :-) Your wasm pkg is ready to publish at /home/pwy/Projects/...


Наведение красоты


Наша математика работает, но внешний вид нашей симуляции… оставляет желать лучшего. Давайте это исправим:


<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Shorelark</title>
  </head>
  <style>
    body {
      background: #1f2639;
    }
  </style>
  <body>
    <canvas id="viewport" width="800" height="800"></canvas>
    <script src="./bootstrap.js"></script>
  </body>
</html>

/* ... */

CanvasRenderingContext2D.prototype.drawTriangle =
    function (x, y, size, rotation) {
        /* ... */

        this.fillStyle = 'rgb(255, 255, 255)';
        this.fill();
    };

CanvasRenderingContext2D.prototype.drawCircle =
    function(x, y, radius) {
        /* ... */

        this.fillStyle = 'rgb(0, 255, 128)';
        this.fill();
    };

/* ... */

Так лучше:



Симуляция еды


На данный момент, когда птички сталкиваются с едой, ничего не происходит — пора это пофиксить!


Сначала немного отрефакторим step():


/* ... */

impl Simulation {
    /* ... */

    pub fn step(&mut self) {
        self.process_movements();
    }

    fn process_movements(&mut self) {
        for animal in &mut self.world.animals {
            animal.position += animal.rotation * na::Vector2::new(0.0, animal.speed);

            animal.position.x = na::wrap(animal.position.x, 0.0, 1.0);
            animal.position.y = na::wrap(animal.position.y, 0.0, 1.0);
        }
    }
}

/* ... */

Теперь:


/* ... */

impl Simulation {
    /* ... */

    pub fn step(&mut self) {
        self.process_collisions();
        self.process_movements();
    }

    fn process_collisions(&mut self) {
        todo!();
    }

    /* ... */
}

/* ... */

Говоря простым языком, мы хотим достичь следующего:


/* ... */

fn process_collisions(&mut self) {
    for каждого животного {
        for каждой еды {
            if животное столкнулось с едой {
                обработка столкновения
            }
        }
    }
}

/* ... */

Проверка столкновения двух многоугольников называется тестированием на попадание (hit testing). Поскольку наши птицы представляют собой треугольники, а наша еда — круги, нам нужен какой-то "алгоритм проверки попадания треугольника в круг".


Но такой вид тестирования на попадание невероятно сложен, так что попробуем что-нибудь попроще, например: предположим, что наши птицы — круги.


Мы можем продолжать рисовать их в виде треугольников — дело в физике.


Итак, тестирование попадания "круг-круг" основано на проверке того, является ли расстояние между двумя кругами меньше или равным сумме их радиусов:





расстояние(A, B) > радиус(A) + радиус(B) ⇒ нет столкновения





расстояние(A, B) <= радиус(A) + радиус(B) ⇒ столкновение


На практике это сводится к одному if:


// libs/simulation/src/lib.rs
/* ... */

pub fn step(&mut self, rng: &mut dyn RngCore) {
    self.process_collisions(rng);
    self.process_movements();
}

fn process_collisions(&mut self, rng: &mut dyn RngCore) {
    for animal in &mut self.world.animals {
        for food in &mut self.world.foods {
            let distance = na::distance(&animal.position, &food.position);

            if distance <= 0.01 {
                food.position = rng.gen();
            }
        }
    }
}

/* ... */

// libs/simulation-wasm/src/lib.rs
/* ... */

#[wasm_bindgen]
impl Simulation {
    /* ... */

    pub fn step(&mut self) {
        self.sim.step(&mut self.rng);
    }
}

/* ... */

Расстояние, возвращаемое nalgebra, выражается в тех же единицах, что и позиции, поэтому, например, расстояние 0.5 означает, что животное и еда находятся на расстоянии половины карты друг от друга, а 0.0 означает, что они находятся в одних и тех же координатах.


0.01 определяет радиус еды. Я выбрал 0.01, потому что кажется, что оно хорошо сочетается с размерами, которые мы используем для рисования.


Это работает? О боже!



На сегодня это все, друзья. Продолжим в следующей статье.




Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале

Комментарии (1)


  1. speshuric
    26.06.2024 20:45
    +3

    Пусть и перевод, но всё равно монументально (читал все части).