Оптимизация js/WebGL/Web Assembly
- суббота, 10 февраля 2024 г. в 00:00:27
Скорость отрисовки, пожалуй, ключевой параметр движка. И по нему можно сравнивать инструменты и принимать решения об использовании в проекте. Технический, скорость обычно ограничивается в 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 раза меньше первоначального. С помощью смещения контролируем положение области исключения.
Тайлы бывают двух видов проходимые и непроходимые. Если мы говорим об игре, проходными могут быть земля и дороги, а непроходными стены и здания. Из непроходных извлекаются координаты для последующего использования в детекторе коллизий. Тут возможны два варианта, извлечь все координаты с самого начала игры, или извлекать только координаты объектов видимых на экране.
В первом случае мы можем столкнуться с уже описанной проблемой, когда объектов слишком много в самом детекторе коллизий.
Во втором случае, чтобы не делать дополнительный проход, нужно все делать вместе с подготовкой отрисовки. Некоторые координаты могут повторяться, нужно сразу отсекать их, опять же, чтобы не делать дополнительной работы потом. Так экономятся драгоценные миллисекунды.
Всегда была интересна технология Web Assembly. Это вроде ассемблера для js, пишутся низкоуровневые инструкции, компилируются и выполняются в js. Используя 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 при обработке массива 300x300 из 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
Пример по ссылке: https://codepen.io/yaalfred/pen/mdoeXQo
Пример по ссылке: https://codepen.io/yaalfred/pen/WNmrLyJ
Результаты могут отличаться, т.к. скорость отрисовки зависит от многих данных - железа, версии браузера и.т.п. Скорость(fps) может также упасть, если вкладка будет неактивна. В целом, при прочих равных условиях, разница между нативной версией и интегрированной wasm должна быть похожей.
Исполняемый код wasm можно посмотреть и даже отлаживать, используя точки остановки, как обычный js, прямо в консоли браузера. Для этого нужно во вкладке debugger найти в разделе wasm:// функцию. На самом деле тут будет показана wat, т.е. человеко-читабельная версия, это довольно удобно для тех кто понимает этот синтаксис.
Первое - что приходит на ум, это сделать метод assembly script идентичный оптимизированному на js, т.е. чтобы он обрабатывал только видимые на экране элементы массива.
Зачастую для реализации физики в играх предлагают использовать деревья, например, Quadtree, которое делит все поле на зоны. Возможно, такой подход будет лучше, чем пересобирать каждый раз элементы для коллизий.
Второе, что можно улучшить - это отрисовка, у меня две программы для рисования примитивов и изображений и на каждый объект для рисования — отдельный вызов webgl.drawArrays() для рисования. Можно объединить программы и сделать сначала подготовку для всех объектов и один вызов рисования в конце.
Еще одна идея — это сделать данные для рисования полностью бинарными, т.е. перенести их в wasm. Не очень понятно как будет тогда осуществляться управление объектами из js. Возможно, можно сделать поиск по id нужных элементов и функции изменения их параметров внутри wasm, либо можно сделать маппинг по адресу в памяти. Если получится, можно будет задействовать большее количество объектов чем в нативном js и создавать, например, полноценную rts на таком движке.
При правильном применении, WebAssembly - очень мощная технология для оптимизации javascript. Технология относительно нова и в популярных js движках пока мало где используется.