688 подписчиков

Параллакс: webgl + rust

128 прочитали

На написание этой статьи меня вдохновил bevy - игровой движок на rust (который, к слову, вполне можно запускать в браузере - там есть вкладка examples, можете посмотреть).

Однако хвалебные оды петь данному движку я не буду: посмотрев на этот пример, я подумал, что он чрезвычайно избыточен, хотя правильней будет сказать, что он не столько избыточен, сколько требует множество шагов, без которых, средствами чистого webgl (и шейдерных программ) можно было бы достичь за сопоставимое время или даже быстрей. Короче говоря, я подумал, что было бы неплохо написать некоторое приложение, использующее rust в качестве основного обработчика (вместо javascript) и интегрированная с webgl. При этом, желательно, большую часть кода оставить на стороне rust. Об этом и будет данная статья.

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

Немного психоделического параллакса вам в ленту
Немного психоделического параллакса вам в ленту

Итак, подключаем Rust

Начнём с того, что подключим пустое приложение rust, которое будет выводить в консоль "Привет, мир!". У меня Debian, но, думаю, инструкции плюс-минус будут похожими и для других операционных систем.

Изначально, идём на сайт с документацией, где подробно описано, как установить wasm-pack. Wasm-pack - это основная утилита, позволяющая делать webassembly сборки и генерирует javascript и typescript файлы, которые позволят интегрировать своё приложение.

Вообще, все эти генерации, по сути, являются просто обёрткой над уже существующими интерфейсами webassembly (если интересно), однако не требует погружения в особенности, что нас, на начальном этапе, более чем устраивает.

Устанавливается набор инструментов для упаковки в wasm всего одной командой (подробнее, тут; rust должен быть уже установлен) :

curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh

Дальше инструкция предлагает использовать демо-проект, но мы, конечно, делать этого не будем.

Вместо этого в папке нового проекта будет создано две других папки: исходники на JS и Rust. Дальше rust-файлы будут собираться в специальную папку, а уже оттуда подключаться к проекту. Собирать всё будет, разумеется, webpack, потому что так мне проще.

Итак, у меня папочка для проекта будет называться rust-parallax; захожу в неё и создаю новый rust-проект:

wasm-pack new rst

Довольно быстро появится папка с некоторым набором файлов; часть файлов нам не особо интересна (например, для travis), мы их удалим, оставим только необходимые:

Минимум
Минимум

Собственно, в этом самом месте уже всё готово для того, чтобы вывести сообщение. Однако, надо рассмотреть ещё файл Cargo.toml:

cargo.toml
cargo.toml

Тут, пожалуй, главная запись на строке 14 - wasm-bindgen - это как раз та зависимость, которая всё будет подготавливать к вебу. Документация - тут. Позже нам потребуется, возможно, js_sys (документация), но это не точно.

Итак, всё установлено, надо теперь собрать: в качестве заглушки уже есть небольшой набор кода, его и используем; там alert, но мы его позже заменим на console.log, как и планировалось.

Со сборкой ничего сложного нет, надо только настроить правильно флаги. Для этого надо просто в соответствующей папке написать

wasm-pack build

Но я немного усложню команду (если набрать wasm-pack build --help, можно увидеть инструкции; я выберу то, что мне нужно), запускать надо из папки, в которой находится Cargo.toml:

wasm-pack build --no-typescript -d ../pkg -t web --dev

Тут я отказался от генерации typescript файлов, выбрал, куда сгружать всё и цель - веб, а также включил режим разработки.

Поначалу всё как-то вообще плохо шло, то есть ничего не происходило (наверное, устанавливались библиотеки, просто в фоновом режиме), а потом вжух - и всё готово:

Журнал установки
Журнал установки

Появилась папка pkg. Заглянем внутрь:

Файлы, которые были созданы
Файлы, которые были созданы

Тут всё просто: rust_bg.wasm - это бинарник, размером почти 40 килобайт (что немало, для вызова всего одной встроенной функции, хотя там есть ещё отладочная информация), rst.js - точка входа для начала работы с бинарником.

Добавим проектные настройки, файл индекса проекта (index.js) и попробуем всё это запустить. Тут я оговорюсь, у меня все проекты собираются одновременно, так что делайте поправки на своё окружение:

Исходники index.js
Исходники index.js

Запускаем и у нас, конечно, ничего не работает. То есть просто белый экран. В логах видно, что wasm-файл загрузился, но что-то пошло не так.

Разумеется, так и должно было быть. Сейчас внимание: wasm-модули должны грузиться асинхронно. Это можно достичь несколькими путями (например так, но мне это ни разу не пригодилось); я же остановлюсь на встроенном в вебпак:

Сперва асинхронно загрузим wasm, затем дождёмся его инициализации
Сперва асинхронно загрузим wasm, затем дождёмся его инициализации
Вот и окошко появилось
Вот и окошко появилось

Теперь завершим опыт и заменим alert на console.log. Для этого придётся вернуться в rust-исходники, найти lib.rs, добавить кое-что и пересобрать всё опять:

Изменим lib.rs, добавим несколько строк
Изменим lib.rs, добавим несколько строк

И вуаля, обещанный результат:

Обещанный вывод в консоль
Обещанный вывод в консоль

Всё, rust работает, собирается. При пересборке - всё само подцепляется к сборке webpack и обновляется на странице. Следующий этап - создать весь интерфейс (насколько это возможно) со стороны wasm-сборки.

Создание холста

Для того, чтобы создать холст (он же canvas) достаточно 1 строчки в html, 3-4 строчки в js и мне понадобилось примерно 27 строчек в rust. Однако всё сразу не заработало как надо, увы. О чём это? О том, что некоторые вещи лучше делать внутри js, а не передавать на откуп сторонним технологиям: и нервы целее будут, и не придётся править бесконечный лог ошибок, что тоже немаловажно.

Пустяковое количество строк кода, чтобы добавить HTMLCanvas
Пустяковое количество строк кода, чтобы добавить HTMLCanvas

И что делать будем?

Полагаю, гибридный режим будет наиболее привлекателен в данном контексте: то, что удобней сделать на чистом js - будем писать на нём, дабы не погружаться в дебри транскрипций js-методов в rust-обёртки.

Что интересней сделать именно на стороне rust - будем делать тут.

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

WebGL

Для начала, надо заглянуть сюда, за документацией. По-сути, надо немного расширить количество импортов в Cargo.toml; после этого можно приступать. Главное, не пытайтесь запускать пример в документации: он не работает и выдаёт такое количество ошибок, что вера в успешный исход тает на глазах.

Также, если вы, всё же, посмотрите код, то можете заметить, что там приложение обращается к контексту webgl2 - это, по сути, тот же webgl контекст, только с более широким набором функций. Я же буду использовать первую версию, т.к. в настоящий момент возможности второй версии мне не сильно нужны.

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

Теперь, по набору действий, которые надо сделать для начала:

  • Включить все необходимые флаги (например, прозрачность);
  • Очистить экран;
  • Создать шейдерные программы для каждой отрисовки;
  • Подключить текстуры, где они присутствуют;
  • Установить вершины фигур, которые будут рисоваться;
  • Отрисовать изначальные изображения параллакса.

Дальше я не буду комментировать каждый свой шаг, которые буду предпринимать. Ссылку на исходники, как и писал ранее, оставлю в конце.

Первый пример

Рисуем прямоугольник разноцветный (задача, убедиться, что всё работает и всё работает как надо: могут вылезти некоторые особенности).

И они-таки вылезли! Метод get_iuniform_location, любезно описанный в документации, отказался присутствовать в сборке web_sys и любая попытка обратиться к нему приводила к недоумению компилятора. Пришлось идти в обход. Не то, чтобы меня такой подход устраивает, но пока пусть будет так.

В общем, не хочу вдаваться в подробности, но с помощью лома и такой-то матери я запустил-таки отрисовку. Получилось так:

Несколько наборов данных объединил в один
Несколько наборов данных объединил в один

Что могу сказать: писать такие вот трансляции с js на rust - то ещё извращение. И вот почему:

  • Не всё, что есть в документации, на самом деле присутствует в библиотеке;
  • Некоторые подходы (например, заталкивание данных в буфер с тем, чтобы передать их в WebGL) требуют более глубокого знания Rust, нежели те, которыми я обладаю;
  • Пример, приведённый в справке, оказался нерабочим и, если бы я не знал, что и в каком порядке надо делать с WebGL, то ничего бы не вышло;
  • Меня-таки прогнули на переход к webgl2-контексту, потому что WebGl2RenderingContext оказался также недоступным, как и некоотрые нужные мне методы (и это мне пока непонятно);
  • Кода получается сильно больше - это не то, чтобы плохо, просто появляется вопрос: "Зачем? Зачем мне мучиться и писать код, который оборачивает другой код, который писать значительно проще?";
  • Думаю (и это моё оценочное суждение), данный подход - имеется в виду прокидывание интерфейсов - был сделан для более широкого охвата функциональности webgl со стороны rust, но получилось, мягко говоря, спорно.

Ну хорошо, а как надо?

Давать советы - дело неблагодарное, так что своё мнение просто выражу. Итак, писать приложения на Rust - безусловно - имеет смысл, равно как и имеет смысл разделять его на части.

Например, мне пришлось перейти в небезопасный режим, для того, чтобы заполнить буфер значениями.

Это, кстати, я взял из примера в документации
Это, кстати, я взял из примера в документации

Если бы не пример в документации, шанс, что я бы догадался, что именно нужно делать - стремится примерно к нулю, т.к. мне ранее не доводилось писать код, где возникала бы необходимость переходить к unsafe. И причины его использования в языке я хоть, теоретически, понимаю, практически - не совсем.

Так что, наверное, лучше отделять мух от котлет, а js код от кода rust. Webgl - это технология ориентированная на js (ну, может быть и на ts в какой-то мере), поэтому лучше её использовать там, где она была создана. Попытка писать непростой для понимания код, да ещё на непростом для понимания языке - усложнение задачи в несколько раз безо всяких на то причин.

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

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

Всё, выплакался. Теперь можно продолжать. Параллакс сам себя не сделает. Пока тут выложу ссылку на текущую версию (ярлык). Маловероятно, но вдруг кто-то захочет посмотреть код и собрать себе красный экран в браузере...

Для будущих поколений даже приложу код из файла effect.rs.

Это то, что рисует квадрат, хотя там присутствуют отдельные функции
Это то, что рисует квадрат, хотя там присутствуют отдельные функции

Да, если вы задаётесь вопросом, как я смог задать каждой вершине цвет всего одним значением - то это очень своевременный вопрос. Я и сам не понял, как так получилось (отвлёкся), но вообще просто срабатывает приведение: цвет это вектор на 4 значения; моё значение заполнило его первую "ячейку", остальные приводятся к значениям по-умолчанию. От этого квадрат просто красный.

Параллакс, продолжение

Теперь, когда мы убедились, что webgl возможен вместе с rust - пойдём дальше. Сделаем 3 прямоугольника, которые будут двигаться с разной скоростью. Тут я хочу ещё немного поэкспериментировать и перейти от 2d контекста к 3d контексту (хотя слово "контекст" тут не совсем уместно).

Второе, это работа со смещениями: теперь я попробую не наращивать смещение от левого угла, а смещать через матричные преобразования (вообще не понимаю, как это делать, особенно если учесть, что uniform не работает). Вызов брошен, вызов принят. Посмотрим, что из этого выйдет.

Для начала, существующий квадрат оставлю и сделаю его просто задним фоном, растянув на весь экран. Дело не хитрое. А затем добавлю ещё несколько прямоугольников: один по центру, второй - ниже и шире, третий - внизу и прямо очень широкий. Типа, гора в центре, которая почти не движется, лес вокруг неё, который немного движется и дорога, которая вечно куда-то бежит. Ну примерно то же, что и в прошлой статье, только немного иначе: зачем одно и то же делать-то?

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

Заготовка для параллакса
Заготовка для параллакса

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

На картинке не видно, но каждый прямоугольник имеет свой z-индекс (проще говоря координаты по оси Z). При таком, стандартном отображении - это никак не влияет на вывод. Ради интереса, я установил для одного из блоков умопомрачительный по своей величине z-индекс - и ничего не поменялось (пока объект оставался внутри области отображения - о ней чуть позже). Однако, скоро мы перейдём трёхмерным величинам и всё поменяется. Ну а пока - так.

2д в 3д

Что хорошо в webgl - так это то, что перейти из режима двумерности в режим трёхмерности так же просто, как решить дифференциальное уравнение в пятом классе: кто-то справится, но большинство даже не поймёт, что произошло.

И тем не менее, давайте немного поменяем код так, чтобы всё стало трёхмерным.

А вот тут начинаются сложности. Дело в том, что изначально библиотека вообще ничего не знает ни про какую трёхмерность, но может отображать данные с некоторым искажением. А вот всё это искажение обеспечить математическим аппаратом должен сегодня я (в js версии у меня была удобная, уже написанная, библиотека для работы с матрицами, но тут я один на один со всей этой математикой).

Итак, чтобы определиться с трёхмерностью, надо учитывать 2 фактора:

  1. Точка обзора: откуда и куда смотрит наблюдатель;
  2. "Коробка" обзора: зона видимости (всё что за ней - будет отсечено, то есть не будет рисоваться);

А это всё матричные преобразования (равно как смещение, поворот, искажение и всё прочее).

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

В передаче данных внутрь WebGL есть три типа:

  • uniform - рассчитан на примитив в целом (неизменны в течении всего вызова отрисовки;
  • attribute - параметр для каждой вершины (положение, нормаль, цвет и тп);
  • varying - параметр для фрагмента (пикселя) - применяется на уровне фрагментного шейдера.

Так вот, мне нужен uniform, так как именно он отвечает за обработку всего изображения (фактически, обзор - это просто искажение изображения таким образом, чтобы создать визуальную видимость перспективы). Для перспективы тоже есть свой метод, но он потребуется немного позже.

Проблема тут заключается в следующем: мне не удалось использовать методы, связывающие uniform данные (об этом я писал выше), а это значит, что надо либо искать в документации какие-нибудь обёртки, позволяющие это сделать, либо думать, как выполнить проброс данных.

Это я печалился по поводу отсутствия uniform
Это я печалился по поводу отсутствия uniform

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

Зависимости для включения uniform
Зависимости для включения uniform

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

Та самая функциональность, которую я не подключил и ничего не работало
Та самая функциональность, которую я не подключил и ничего не работало

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

Выглядит как и раньше, но теперь можно менять точку обзора
Выглядит как и раньше, но теперь можно менять точку обзора

Теперь немного устных объяснений: я поменял точку обзора таким образом, что теперь мы смотрим не сверху вниз на центр, а чуть-чуть сместились по оси Y вниз, отдалились на 1.0 по оси Z и смотрим в центр.

А вы ведь ждали, что будет трапеция, да?
А вы ведь ждали, что будет трапеция, да?

Я тоже ждал, что получится трапеция. Но она не получится и вот почему: мы не задали перспективу. Иными словами, сейчас искажение не учитывает того, что какие-то точки дальше, какие-то ближе. В принципе, это удобно, т.к. можно использовать различные проекции (например, ортогональную).

Пример ортогональной проекции: всё одного размера, но, вроде, объёмно
Пример ортогональной проекции: всё одного размера, но, вроде, объёмно

Но нам нужна перспектива, так что надо добавить метод создания перспективы: set_perspective.

А вот так вот выглядит изображение, когда наложили ещё и перспективу
А вот так вот выглядит изображение, когда наложили ещё и перспективу

Небольшое отступление

Тут, надо сказать, всё очень непросто для понимания. Дело в том, что полученный эффект проявился в результате перемножения матриц перспективы и вида, а, затем, всё это было помножено на вектор текущих координат. При этом порядок имеет значение!

Выглядит это так:

gl_Position = uPerspectievMatrix * uViewMatrix * a_Position;

Если после этих строк вы решили, что пускай WebGL останется кому-то другому - не рискну вас осудить. Особенно, если принять во внимание тот факт, что если перепутать порядок матриц местами - не получится ничего. Более того, всякие там игрища с параметрами перспективы (а это, надо сказать, 4 параметра: угол усечённого конуса области видимости, коэффициент соотношения длины и ширины коробки вывода, самая ближняя граница и самая дальняя граница) дают, поначалу, весьма непредсказуемый эффект.

Это графическое объяснение перспективы; в принципе, понятней не стало
Это графическое объяснение перспективы; в принципе, понятней не стало

Короче, если хотите разобраться, что тут и к чему - проще будет прочитать вот эту книгу ("webgl программирование трехмерной графики", Коичи Мацуда и Роджер Ли).

Продолжаем.

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

Добавил немного искажения
Добавил немного искажения

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

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

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

Однако, довольно скоро возникла другая проблема, связанная с отрисовкой - необходимость многократной перерисовки экрана. Для этого, обычно, используется метод requestnimationFrame, однако в этот раз применить его, как привыкли, не получится. И вот почему: сама модель работы rust не является асинхронной по умолчанию, а методу нужна js функция. В документации описан пример, который, конечно, не работает (ну никто и не ожидал ничего иного), однако, в целом, вполне себе работоспособен (но чтобы понять, как это чудо происходит - моей подготовки, увы, не хватило, но я не сдаюсь)!

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

Память в начале наблюдения
Память в начале наблюдения
Примерно через 5 минут работа приложения
Примерно через 5 минут работа приложения
И почти сразу же
И почти сразу же
А чуть позже стало меньше, чем в начале
А чуть позже стало меньше, чем в начале

Можно заметить, что память "плывёт" в рамках статистической погрешности; процессор загружен тоже, примерно, одинаково. Приемлемо!

Единственно, в чём я ошибся - это в том, что рассчитывал, что программы целиком смогут сохранить данные и не потребуется их опять записывать в буфер. В принципе, так можно было бы сделать, однако мой подход немного нарушил такую логику (вместо одного файла написал три; так можно, но не надо). Пришлось переписать, т.к. в противном случае отрисовывался лишь последний примитив.

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

Рассматриваем нашу фигуру с разных сторон
Рассматриваем нашу фигуру с разных сторон

Это уже что-то, но, конечно, ещё не тот параллакс, который мы заслужили. Хотя, если присмотреться, то видно, что немного, совсем чуточку, параллакс-эффект прослеживается. Это происходит оттого, что я изначально дал примитивам разные координаты по оси Z. Движемся дальше.

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

Добавили горизонтальную поверхность
Добавили горизонтальную поверхность

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

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

Всё движется с разной скоростью
Всё движется с разной скоростью

Можно ли было сделать лучше? Конечно. Но в данной статье я пытался не сделать идеально (что, возможно, было бы более правильным решением), а обкатать технологию.

Заключение

Могу сделать только три вывода:

  1. На Rust можно писать webgl приложения;
  2. По возможности я буду этого избегать;
  3. Когда в следующий раз мне понадобится параллакс - подыщу нормальные текстуры и пофантазирую над прочими способами передать эффект.

Посмотреть результат можно тут, а исходники - здесь.