Оптимизация js/WebGL/Web Assembly

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

Зачем вообще делать оптимизацию

Скорость отрисовки, пожалуй, ключевой параметр движка. И по нему можно сравнивать инструменты и принимать решения об использовании в проекте. Технический, скорость обычно ограничивается в 60 fps, это примерно 16мс на цикл отрисовки. Можно подумать, что если вы достигли такого результата, то дальше оптимизировать движок нет смысла, но это не так. Отрисовка потребляет память и процессорные мощности. Программа, которая потребляет меньшее количество компьютерных мощностей при прочих равных возможностях - эффективней и лучше. Ну а сделать лучше, это ли не то к чему нужно стремиться?

Нативная оптимизация

Самой ресурсоемкой функцией в движке является метод для подготовки данных из файла .tmj, это json-файл который формирует редактор Tiled, внутри находится массивы карты с индексами тайлов:

{ "compressionlevel":-1,
 "height":30,
 "infinite":false,
 "layers":[
        {
         "data":[109, 163, 9, 49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 31, 163, 163, 163, 163, 163, 163, 163, 163, 163, 163, 163, 163,
            49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 31, 163, 163, 163, 163, 163, 163, 163, 163, 163, 163, 163, 163],
         "height":30,
         "id":1,
         "name":"ground",
         "opacity":1,
         "type":"tilelayer",
         "visible":true,
         "width":40,
         "x":0,
         "y":0
        }, ...
  ]
}

Для отрисовки нужно перебрать его, собрать координаты каждого тайла, координаты его текстуры, индекс текстуры, записать в буфер и отправить все на webgl для отрисовки. И если карта большая, операций становится слишком много.

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

Пример. Карта 40х30 ячеек. Ячейки 16х16 пикселей. Всего 1200 ячеек. Экран мобильного, т.е. рабочая область - 360х800 пикселей, на таком экране помещается 22.5 ячеек в ширину и 50 в высоту, т.е. при переборе массива, мы исключаем все колонки после 23, получаем 690 ячеек для перебора, а это уже почти в 2 раза меньше первоначального. С помощью смещения контролируем положение области исключения.

пример обрезки небольшого массива. offsetX = 0; offsetY = 0;
пример обрезки небольшого массива. offsetX = 0; offsetY = 0;

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

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

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

Web Assembly

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

Терминология Web Assembly используемая в статье

AssemblyScript – тайпскрипт-подобный язык, который компилируется в текстовый wat и wasm.

wat – текстовое представление инструкций wasm.

wasm – скомпилированный бинарный файл для исполнения.

Для эксперимента я взял упрощенную версию описанной в начале статьи функцию для обработки файлов .tmj, которая просто последовательно перебирает все элементы и подготавливает данные для отрисовки в webgl:

function calculateBufferDataOriginal(layerRows, layerCols, layerData, dtwidth, dtheight, tilewidth, tileheight, atlasColumns, atlasWidth, atlasHeight, setBoundaries) {
    let verticesBufferData = [],
        texturesBufferData = [],
        mapIndex = 0;
      
    for (let row = 0; row < layerRows; row++) {
        for (let col = 0; col < layerCols; col++) {
            let tile = layerData[mapIndex],
                mapPosX = col * dtwidth,
                mapPosY = row * dtheight;
            if (tile !== 0) { // отбрасываем пустые тайлы
                tile -= 1;
                const atlasPosX = tile % atlasColumns * tilewidth,
                      atlasPosY = Math.floor(tile / atlasColumns) * tileheight,
                      // в webgl все координаты нужно привести к (-1, +1)
                      // приведение позиции на экране делается в вершинном шейдере
                      vecX1 = mapPosX,
                      vecY1 = mapPosY,
                      vecX2 = mapPosX + tilewidth,
                      vecY2 = mapPosY + tileheight,
                      // а для текстур делаем это отсюда
                      texX1 = 1 / atlasWidth * atlasPosX,
                      texY1 = 1 / atlasHeight * atlasPosY,
                      texX2 = texX1 + (1 / atlasWidth * tilewidth),
                      texY2 = texY1 + (1 / atlasHeight * tileheight);
                // каждый тайл - прямоугольник, дробится на 2 треугольника,
                // по две координаты (x,y) получается 12 координат 
                // позиции на карте
                verticesBufferData.push(
                    vecX1, vecY1,
                    vecX2, vecY1,
                    vecX1, vecY2,
                    vecX1, vecY2,
                    vecX2, vecY1,
                    vecX2, vecY2);
                // и 12 координат текстуры на текстурном атласе
                texturesBufferData.push(
                    texX1, texY1,
                    texX2, texY1,
                    texX1, texY2,
                    texX1, texY2,
                    texX2, texY1,
                    texX2, texY2
                );
            }
            mapIndex++;
        }
    }
    return [ verticesBufferData, texturesBufferData ];
}

Далее я написал версию на AssemblyScript и сделал массив для обработки. Версия AssemblyScript показала x6 к скорости по сравнению с нативной, если не учитывать время инициализации. 300х300 не пустых ячеек (90 000 элементов) обрабатывались в nodejs(версии 20, 17) с помощью нативной функции ~30мс и ~5мс с помощью wasm. При уменьшении количества элементов, скорость меняется кратно, например, 120х60: нативная версия ~4.5 мс, wasm ~0.8 мс.

Сравнение js и wasm в обработке из nodejs
Сравнение js и wasm при обработке массива 300x300 из nodejs
Сравнение js и wasm при обработке массива 300x300 из nodejs

Скорость выполнения нативного js и wasm при обработке массива 300x300 из nodejs.

Сравнение js и wasm при обработке массива 120x60 из nodejs
Сравнение js и wasm при обработке массива 120x60 из nodejs

Скорость выполнения нативного js и wasm при обработке массива 120x60 из nodejs

Интеграция

При интеграции wasm в движок, рендер получился в два-три раза быстрее для обработки с wasm.

Для демонстрации я создал карту 200х200 не пустых ячеек по 16 пикселей. Я также убрал ограничение фремрейта 60 fps для теста:

SystemSettings.gameOptions.render.minCycleTime = 0; // ограничение скорости цикла отрисовки (в мс)

Тестировал на лаптопе i5-1240P, 16 GB.

  • Chrome@120.0.6099.131 x3.5 раза быстрее: ~30 fps / ~110 fps

  • Firefox@122.0b5 х2 быстрее: ~40 fps / ~80 fps

Пример рендера 200х200 с неоптимизированной javascript функцией
нативная javascript версия firefox
нативная javascript версия firefox
нативная версия в Chrome
нативная версия в Chrome

Пример по ссылке: https://codepen.io/yaalfred/pen/mdoeXQo

Пример рендера 200х200 с неоптимизированной wat функцией
скорость с интегрированным wasm firefox
скорость с интегрированным wasm firefox
интегрированный wasm в Chrome
интегрированный wasm в Chrome

Пример по ссылке: https://codepen.io/yaalfred/pen/WNmrLyJ

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

Отладка wasm

Исполняемый код wasm можно посмотреть и даже отлаживать, используя точки остановки, как обычный js, прямо в консоли браузера. Для этого нужно во вкладке debugger найти в разделе wasm:// функцию. На самом деле тут будет показана wat, т.е. человеко-читабельная версия, это довольно удобно для тех кто понимает этот синтаксис.

Wasm в консоли браузера
Как найти и отлаживать скомпилированный wasm
Как найти и отлаживать скомпилированный wasm

Какие еще оптимизации еще можно сделать.

Первое - что приходит на ум, это сделать метод assembly script идентичный оптимизированному на js, т.е. чтобы он обрабатывал только видимые на экране элементы массива.

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

Второе, что можно улучшить - это отрисовка, у меня две программы для рисования примитивов и изображений и на каждый объект для рисования — отдельный вызов webgl.drawArrays() для рисования. Можно объединить программы и сделать сначала подготовку для всех объектов и один вызов рисования в конце.

Еще одна идея — это сделать данные для рисования полностью бинарными, т.е. перенести их в wasm. Не очень понятно как будет тогда осуществляться управление объектами из js. Возможно, можно сделать поиск по id нужных элементов и функции изменения их параметров внутри wasm, либо можно сделать маппинг по адресу в памяти. Если получится, можно будет задействовать большее количество объектов чем в нативном js и создавать, например, полноценную rts на таком движке.

В заключение

При правильном применении, WebAssembly - очень мощная технология для оптимизации javascript. Технология относительно нова и в популярных js движках пока мало где используется.

Источник: https://habr.com/ru/articles/792642/


Интересные статьи

Интересные статьи

Привет, друзья! Представляю вашему вниманию перевод этой замечательной статьи, в которой автор рассказывает о том, как разработать компилятор для WebAssembly на TypeScript. Обратите внимание: ...
После переписывания Cyberscore я захотел отправить на сайт какие-нибудь результаты. Последнее, во что я играл, это Pokémon Legends: Arceus, по которой на Cyberscore есть около 3000 таблиц результато...
Привет, Хаброжители! В этой книге опытный преподаватель Марк Прайс дает все необходимое для разработки приложений на C#. В пятом издании для работы со всеми основными операционными системами использу...
Пока одни обсуждают что не так с WebAssembly, я думаю как его можно использовать вне браузера. Например написание wasm фильтров для Envoy. AssemblyScript был взят потому, что это не C++ и...
В мае этого года мы обсуждали алгоритм, который используем для генерации внешнего мира в игре Fireside. Сегодня мы возвращаемся к этой теме. В прошлый раз нам удалось сгенерировать набор ...