Оптимизация 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 при обработке массива 300×300 из nodejs

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

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

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

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

Интеграция

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

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

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

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

Пример рендера 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 движках пока мало где используется.

© Habrahabr.ru