Это предпоследняя часть серии статей по разработке симуляции эволюции с помощью нейронной сети и генетического алгоритма.
В сегодняшнем выпуске:
Сексуальные многоугольники
Сертифицированные ISO диаграммы ASCII
------------
| ...%....|
| ......|
| @>....|
| ...|
| .|
------------
Клевые числа
После того, как мы реализовали нейронную сеть и генетический алгоритм, нас ждет самая восхитительная часть: моделирование экосистемы и отображение танцующих треугольников на наших экранах!
Предупреждение: эта статья содержит код JavaScript.
Не беспокойтесь, если JavaScript (далее также — JS) или HTML вам незнакомы, я постараюсь объяснить все концепции по ходу дела.
Проект
У нас есть struct NeuralNetwork
и struct GeneticAlgorithm
, а как насчет struct Eye
или struct World
? Ведь у наших птиц должны быть глаза и место для жизни!
Вот чем мы будем заниматься сегодня и в следующей заключительной статье: мы реализуем функции, которые будут определять, что видит птица
или как она двигается
. Мы также создадим пользовательский интерфейс, который позволит нам видеть больше, чем сухие числа — пришло время лучше задействовать нашу зрительную кору!
Если вам нравятся диаграммы, то мы стремимся к следующему:
Как всегда, помимо кодирования, мы также исследуем, что именно происходит под всеми слоями абстракций, которые нам встретятся. Если вы не хотите этого знать, не стесняйтесь пропускать соответствующие разделы.
Готовы? Тогда вперед.
Предварительные условия
Для работы с WebAssembly (далее также — WA) нам понадобятся два дополнительных инструмента:
Прежде чем продолжить, установите эти инструменты.
Привет, WA (Rust)!
Начнем с создания нового крейта, отвечающего за взаимодействие с фронтендом:
Формально такой модуль для "общения с другой системой" называется мостом (bridge) или модулем взаимодействия (interop).
cd shorelark/libs
cargo new simulation-wasm --lib --name lib-simulation-wasm
Для того, чтобы наш крейт поддерживал WA, нам нужно добавить в его манифест 2 вещи:
- Нам нужно установить 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() { ... }
.
- Нам нужно включить 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
загружает и выполняет файл JSp
(отсутствует в примере) оборачивает текст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 для этого нужны отдельные инструменты.
Мы узнали своего врага, что дальше?
Обменявшись любезностями с файловой системой, вернемся к делу:
- На данный момент
npm
не знает оlib-simulation-wasm
, исправим это:
// www/package.json
{
/* ... */
"devDependencies": {
"lib-simulation-wasm": "file:../libs/simulation-wasm/pkg",
/* ... */
}
}
- Сообщаем
npm
об этом изменении:
cd www
npm install
- Пришло время
index.js
:
import * as sim from "lib-simulation-wasm";
alert("Who's that dog? " + sim.whos_that_dog() + "!");
Здесь нет
function main() { }
, потому что в JS она не нужна, весь код выполняется сверху вниз (более или менее).
- Запускаем приложение (по сути, это
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
, потому что кажется, что оно хорошо сочетается с размерами, которые мы используем для рисования.
Это работает? О боже!
На сегодня это все, друзья. Продолжим в следующей статье.
Автор: Igor Agapov