Последние три года автор занимается разработкой компонента C3D Web Vision. Это модульное клиент-серверное решение для визуализации 3D в браузере, которое легко интегрируется с любым веб-приложением. В публикации мы поделимся нашим опытом разработки и расскажем об инструментах, которые используем при разработке C3D Web Vision. Предлагаем рассмотреть графическое API, применяемое в браузере, сборку C++ проекта под веб и работу с микросервисом.
Первые шаги
C3D Labs начала разработку веб-визуализации три год назад. У нас уже был опыт создания десктопных продуктов. Перед началом разработки мы посмотрели на наше приложение C3D Viewer и задумались, как его перенести в веб-среду. Выделили основные модули: модуль десктопной визуализации C3D Vision, модуль математики C3D Modeler, модуль конвертора C3D Converter, бизнес-логику, написанную на C++, и графический интерфейс на Qt. Компоненты C3D Modeler и C3D Converter перенесли на сторону сервера, модули без проблем работают на backend без наших дополнительных доработок. Business logic решили реализовать на typescript, чтобы связать ее с интерфейсом. Для реализации интерфейсной части мы взяли фреймворк Vue. В качестве библиотеки визуализации мы решили оставить собственное решение — C3D Vision и собрать в web assembly. Это оказалось самой сложной частью разработки.
Typescript
В браузере выполняется java script, но на самом деле большинство программистов не хочет работать с java script. Причиной этому является то, что это сложный язык, динамический, не типизированный, в нем постоянно приходится «наступать на грабли». В основном разработка ведется на typescript. Этот язык типизирован, есть возможность реализовать объекты классов. И мы не стали исключением, вся frontend-часть проекта у нас написана на typescript. На рис. 1 слева вы видите написание класса на typescript, а справа — результат сборки typescript в java script.
Рис. 1
Согласитесь, typescript выглядит приятнее, а на этапе отладки разобраться в java script будет уже тяжеловато.
У typescript и java script есть модули. В основном программисты, разрабатывая программный продукт, разбивают его на отдельные части, куски — логически выделяя файлы, подпапки и модули. То же самое можно сделать и в typescript. Единственная проблема в том, что java script работает не только в браузере, но может функционировать и на backend под управлением node. Поэтому разновидностей модулей для java script очень много: commonjs, umd, amd. Пример этих модулей приведен на рис. 2.
Рис. 2
Слева изображен пример, написанный на typescript, а справа — umd-модуль, с которым работать не очень удобно. Причем для того, чтобы в браузере все заработало, к правому модулю нужно подключить зависимую стороннюю библиотеку.
Moдули commonjs, umd, amd
Разработчики могут программировать собственные модули (что мы и сделали с модулями визуализации)и делиться ими с другими разработчиками. В состав модуля обычно входит package.json, сам java script и описание typescript. С помощью такого инструмента, как npm, можно подключать внешние модули в свой проект и публиковать их для других пользователей. На рис. 3 показано, как примерно выглядит подключение npm-модуля.
Рис. 3
В середине рисунка изображен package.json, внизу располагается devDependencies, где мы подключили наш C3D Vision wasm. Справа показано, как мы работаем с ним внутри самого исходного кода. Мы импортируем обычный модуль и начинаем его использовать. На этапе, когда мы уже работаем с внешними модулями, в директории проекта появляется папка node_modules, а на этапе инсталляции в нее подкачиваюся все зависимые модули.
Для того чтобы из большого массива исходных файлов собрать уже готовый модуль, мы использовали webpack. Это очень мощный инструмент, который позволяет пподключать к сборке проекта ресурсы, а также компилятор typescript, уменьшать результирующий файл со скриптами, включать в проект разные типы модулей, генерировать проект под браузер или node и т.д.
Worker, многопоточность, promise
В своей практике автор иногда сталкивается с тем, что разработчики не видят различий в понятиях «многопоточный» и «асинхронный» код. Асинхронный код может работать как в нескольких потоках, так и в одном. Многопоточная программа тоже может быть синхронной и асинхронной, например, когда вы смотрите фильм, обработка звука и картинки выполняется в разных потоках, но синхронно, чтобы голос не отставал или не убегал от картинки. В браузере весь код выполняется асинхронно.
На рис. 4 изображен Task 1.
Рис. 4
У первой задачи выполняется запрос на загрузку (она что-то загружает, запрашивает какой-то файл с сервера), а после ожидания загрузки запускаются Task 2 и Task 3. Но с помощью promise мы можем решить эту задачу таким образом: запустить загрузку и одновременно с этим будут выполняться последующие задачи. Тут можно сказать, что в этом случае всё работает в два потока.
А теперь рассмотрим другой пример. Если у нас есть продолжительная по времени задача, рекомендуем делить ее на маленькие подзадачи (рис. 5). Для этого нужно через какой-то промежуток времени делать тайм-ауты, чтобы можно было, например, с помощью UI прервать задачу. Иначе может сложиться ситуация, что при блокировке браузера на 20 секунд он перестанет отвечать на наши запросы и пользователь, открывая-закрывая вкладку, не будет понимать, почему все зависает.
Рис. 5
На самом деле в браузере можно выполнять код в нескольких потоках. Для этих целей предусмотрен webworker, который представляет собой отдельный поток или процесс (рис. 6), позволяющий выделить решение долгоиграющих задач в другой поток, отдельный от основного. Существует два вида worker: задачи shared и обычные. Их различие заключается в том, что shared worker можно использовать между двумя разными вкладками браузера. Например, для прослушивания в одной вкладке вы запустили Яндекс.Музыка, а потом открываете вторую вкладку, в которой можете управлять плеером.
Хочется упомянуть о работе с данными между потоками. Нельзя получить доступ к данным из другого потока, их необходимо передать (скопировать) через механизмы сообщений. Большие данные, например массивы, можно перемещать.
Local store, offline mode
Существует еще один вид worker — service worker. Его отличиe от обычного и shared worker состоит в том, что он работает даже тогда, когда браузер выключен. Он необходим для реализации такой задачи, как offline mode — инструмент, который позволяет нам реализовать режим работы с данными без подключения к Интернету. Например, мы подключились к рабочей сети, чтобы открыть модельку, синхронизировали данные с сервером, а потом поехали в командировку. В первом случае в service worker мы могли сохранить данные в кэше браузера, а во втором случае сервис выступил бы в виде офлайн-сервера и поднял бы нам все данные из кэша браузера. Для работы с данными существует четыре вида хранилища:
- IndexDB — хранилище базы данных;
- LocalStorage — хранилище для ключа значения;
- SessionStorage — хранилище для ключа значения;
- Storage — хранилище, которое позволяет использовать 50% диска.
API для визуализации в браузере
С выходом HTML5 появился такой тег, как canvas, позволяющий рисовать в браузере 2D- и 3D-графику. Сейчас для 3D-визуализации доступны WebGl и WebGl2. В 2022 году WebGL2 стал официально поддерживаться на всех устройствах, поэтому теперь всем разработчикам рекомендуют переходить на WebGl2. API WebGL почти полностью отражает возможности на OpenGL/ES, но есть небольшие различия в некоторых функциях. Например, при работе с памятью. Поскольку браузер имеет свою особенность работы с памятью, такая функция, как glMapBufferRange, недоступна в вебе. Она реализована через функцию glGetBufferSubData, а в OpenGl ES ее нет.
Рис. 6
Кто работал с OpenGL, знает о shared context — возможности рисовать в двух окнах одну и ту же модель одновременно. Вроде как в браузере тоже есть shared context (в документации), а на самом деле ни один браузер ее не поддерживает. Выход есть — это offscreen context. Например, мы можем рисовать не в канву (canvas), а сразу в картинку, а картинку, в свою очередь, выводить на canvas. Если использовать offscreen context совместно в shared web worker, то можно рисовать в двух вкладках одновременно. Это очень удобная возможность, если у вас более одного монитора. В таком случае интерфейс управления можно вывести в одну вкладку, а визуализацию 3D-модели — в другую вкладку, тем самым разделив управление и визуализацию (рис. 7).
Рис. 7
Например, на одной вкладке выбираем узлы модели в интерфейсном представлении дерева модели, а на другой отрабатываем выделение графической части в окне визуализации, подсвечивая необходимую геометрию.
В III квартале 2023 года запланирован выпуск WebGPU — это уже более продвинутая графика на основе Vulcan и DirectX12. Сегодня она доступна в браузере для разработчиков, но в пользовательских браузерах ее еще нет.
C++ в браузере
Самое интересное — это то, как мы решили собирать C++-проект под веб. Существует компилятор emscripten, который позволяет собрать C++-проект для выполнения в веб, в результате чего мы получаем два файла — java script и webassembly. Wasm — это ассемблерный код для браузера, то есть браузер воспроизводит его так же, как и JavaScript. На рис. 8 показано, как выглядит исходный текст на C++, который мы собрали, а справа — инициализация собранного модуля, который выведет в текст output.
Рис. 8
Модуль hello.js подключается к странице в виде глобальной переменной, к которой добавляется функция-обработчик onRuntimeInitialized. Эта функция вызывается, когда wasm скомпилирован браузером и инициализированы все binding. После этого мы можем использовать функции из wasm.
Emscripten очень хорошо интегрируется с CMake и conan. Для того чтобы использовать внешние conan-библиотеки для веб-сборки, необходимо в рецепте указать архитектуру wasm. А для сборки зависимостей под веб необходимо добавить в зависимость emsdk_installer. На этапе сборки conan подменит компилятор из системы (например, gcc) на emcc. В проекте мы можем использовать заголовочные файлы, входящие в состав emsdk, например OpenGL, который на этапе сборки подменяет функции OpenGL на функции WebGl. Для нас ничего не меняется — у нас как был кроссплатформенный код, так он и остается.
Для того чтобы была возможность вызывать функцию C++ из java script, на этапе сборки у нас есть два варианта связки (binding) — Embind (который мы используем) и WebIDl.
На рис. 9 слева представлен список типов, которые можно использовать, — из него видно, что у Embind гораздо больше возможностей.
Рис. 9
Так это выглядит внутри исходных кодов (рис. 10).
Рис. 10
Слева на рис. 10 с помощью специальных макросов мы описываем класс, как он будет выглядеть в java script. Справа мы пишем definition, который описывает, как класс выглядит в typescript. Поскольку результатом сборки является java script-файл, а мы работаем с typescript, то typescript definition нам приходится описывать вручную.
Сборка C++-проекта под веб: проблемы
Результатом сборки С++-проекта под веб является java script-модуль, а именно экспортная функция, которая загружает wasm-файл для компиляции и возвращает promise с модулем. Поскольку модуль в этом случае ведет себя не совсем типично, возникает проблема с описанием его в typescript. Для ее решения мы реализовали обертку, которая экспортирует объект с функцией инициализации модуля. Существуют еще проблемы с зависимостями от wasm-файла: так как мы поставляем библиотеку визуализации, то всегда необходимо соблюдать условие, что wasm-файл должен быть подключен в проект, и об этом не нужно забывать. На помощь приходит webpack, позволяющий собрать проект таким образом, что wasm-файл будет содержаться в проекте, как ArrayBuffer.
Есть еще пара нюансов, о которых следует знать при сборке C++ под веб. Во-первых, для объектов, созданных в C++-модуле, необходимо вызывать деструктор, так как ресурсы используют wasm-память (эмуляция кучи). Для java script используется garbage collector, а для С++-объектов garbage collector не работает, потому что вся память для С++-объектов представляется в одном array buffer. С этим можно смириться, главное — не забыть вызвать деструктор.
Во-вторых, по умолчанию у нас есть ограничение по памяти в 2 гигабайта. Его можно расширить до 4 с помощью опции компиляции, но существует риск столкнуться с проблемой OpenGl, потому что в компиляторе, а именно в модуле OpenGL, есть ошибки. Мы смогли пропатчить компилятор и исправить эти ошибки, а именно — добиться правильной работы OpenGL при использовании 4-гигабайтной памяти. Но сами вкладки тоже имеют ограничение в 4 гигабайта в браузере.
В-третьих, у нас медленные вызовы функций C++ из java script, что описано в документации. Это объясняется тем, что все-таки С++ — типизированный язык, а java script всё равно какую переменную использовать, поэтому внутри установлено много проверок: передали нам указатель или нужную переменную и т.д. Во избежание этой проблемы можем посоветовать пореже вызывать функции С++ из java script, если такое возможно. У нас были проблемы с загрузкой данных, например, при загрузке большой сборки. Там очень много мелких объектов, из-за чего существенно терялась производительность. Нам пришлось перенести всю сборку на уровень C++. Всю реализацию мы уже выполняем внутри C++, на уровне typescript мы выделяем с помощью внешних функций кусок памяти, кладем туда загруженный буфер, а потом сериализуем плюсовые объекты внутри wasm-модуля. Это обеспечило нам существенный прирост в производительности.
Кроме того, существует проблема с инструментами отладки. Так выглядит отладка C++-кода в браузере (рис. 11).
Рис. 11
Посередине кусок кода, мы можем поставить точку на отладку, прийти туда, но никогда не увидим там переменные, а также что означает каждая переменная. Это показано справа под номерами var 23, var 24. Есть небольшой стек справа, но он не очень информативный.
В данном материале автор поделился опытом разработки front-части наших графических веб-приложений на примере C3D WebVision. Это наш взгляд на разработку веб-версии программных продуктов. Надеемся, наш опыт будет полезен и поможет компаниям-разработчикам в создании веб-приложений.