Последние полгода ко мне приходили задачи, которые вынуждали покопаться в некоторых особенностях WebAssembly.

Это, в свою очередь, подтолкнуло меня покодить на Си (чего я не делал сто лет) всякое, ориентируясь на WASM.

А это уже натолкнуло меня на мысль: а не подойдёт ли связка JS+WebAssembly как инструмент для создания минифицированных проектов в духе демосцены?

Короче, хоть я к демосцене никаким боком, но что мешает мне написать игрульку в сайз-факторе 4K? Исключительно for fun.

(Как оказалось, для такой деятельности есть умное слово «sizecoding»)

Оговорюсь: данная статья не HOWTO или учебник, а скорее маленький дев-лог развлекательно-публицистического характера с техническим уклоном.

Это не первый мой опыт такого плана, ранее я пытался разобраться, как подходят к работе JS-сценеры и какие инструменты используют.

Тогда у меня получился итоговый «продукт» в виде data-uri размером 3654 байта (вот ссылка, называется distance-21).

У проекта distance-21 получился забавный побочный эффект. 3654 байта можно просто кинуть в Телеграм друзьям, чтобы они офигели, а потом играть и сравнивать, кто больше очков настрелял.

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

  1. Проект должен быть самодостаточным HTML-файлом, закодированным в data-uri (чтобы можно было вставить его в адресную строку браузера).

  2. Итоговый data-uri должен быть меньше 4096 байт (чтобы можно было заслать его в Телеграм одним сообщением).

  3. Проект должен быть игрой, по фичам сопоставимой с distance-21 (чтобы был хоть какой-то геймплей).

  4. Управление, как и в distance-21, должно быть «одной кнопкой» (чтобы одинаково работал на разных устройствах).

Суть статьи одной картинкой
Суть статьи одной картинкой

Погнали

Точнее: «И я погнал». План мне виделся таким:

  1. Пишу как удобно, не заморачиваясь, пока не завершу задуманные фичи.

  2. Начинаю оптимизацию без фичеката.

  3. Если упираюсь в размер, то режу фичи.

Архитектура мне виделась примерно такой. Внутри, в WASM, хранится стейт, и нар��жу вытащены три функции: просчёт шага симуляции, отрисовка стейта на пиксельном буфере и обработка события ввода.

Снаружи, в JavaScript, будет canvas, вызов пересчёта симуляции по setInterval и вызов отрисовки на буфере по requestAnimationFrame.

Ну и onmousedown с onkeydown будут пробрасываться в WASM.

Как-то так, короче:

// сишный код, компилируемый в WASM 
// (забавно, Хабр не предлагает вариант подсветки синтаксиса "Си")
struct { /*...*/ } state;

void input() { /*...*/ };

void process() {
  static unsigned int procFrame = 0;
  /*...*/
  procFrame++;
}

void render(int t, byte *input) {
  static unsigned int frame = 0;
  /*...*/
  frame++;
}
// яваскрипт-код, встраиваемый в HTML
const Z = await fetch("app.wasm");
WebAssembly.instantiate(Z).then(
({instance}) => {
const pixPtr = instance.exports.__heap_base.value;
const pixBuf = new Uint8Array(instance.exports.memory.buffer)
                .subarray(pixPtr, pixPtr + BSIZE)
const canvas = document.getElementById("c")
const ctx = .getContext("2d")
const image = ctx.getImageData(0, 0, CSIZE, CSIZE)

// обработка физики
setInterval(() => { 
    instance.exports.process() }, 1000 / 60)
});
// ввод
document.onmousedown = document.onkeydown = () => {
    instance.exports.input()
}
// рендер
const processFrame = (t) => {
    instance.exports.render(t, pixPtr)
    image.data.set(pixBuf)
    ctx.putImageData(image, 0, 0)
    requestAnimationFrame(processFrame);
}
processFrame(0)
});

Объяснение основ разработки под WebAssembly я опускаю. Если читателю это нужно/интересно, в конце статьи будет пара полезных ссылок.

Встраивание

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

Фактически мне нужно было заменить:

const Z = await fetch("app.wasm");

На:

const Z = new Uint8Array([0,97,115,109,/*...контент файла*/]);

Нарисуем инлайнер на коленке:

const fs = require('node:fs');
const fileName = process.argv[process.argv.length - 1];
const data = fs.readFileSync(fileName);
console.log(
    `const Z = new Uint8Array([${new Uint8Array(data).toString()}]);`,
    `\n/*-*/`,
)

Это вполне себе работает, но размер! Пришла пора заняться сжатием.

Массив байт, записанный десятичными цифрами
Массив байт, записанный десятичными цифрами

Инструменты разработки и сжатия

Небольшое отступление вбок. Для разработки я выбрал просто clang без emscripten.

Ради ровно одной фишки я писал на стандарте C23.

Сходу вылезло две проблемы по причине «ох давненько я не брал в руки шашку Сишку».

Первое: нужно указывать и стандарт, и язык:

clang --std=c23 --language=c #...

Иначе компилятор норовит какой-нибудь файл считать то C++, то Objective-C.

Второе: ключ оптимизации "-Oz" («прям сильно старайся размер уменьшить») использует wasm-opt, если он установлен, а устанавливается он не с clang, а с binaryen. Прикольно было. На двух разных системах одна и та же версия clang с "-Oz" из одних и тех же исходников выдавала WASM-файлы разных размеров.

Для сжатия яваскриптовой обвязки использовался опыт предыдущего проекта: популярный минификатор uglify-js и широко известный в узких кругах демосценеров regpack — утилита для создания сжатого самораспаковывающегося яваскрипта.

regpack оптимизирует повторения последовательности символов, так что он неплохо сжимает вышеупомянутый жирный массив в JS-исходном коде.

Если подытожить, то первый пайплайн был такой:

  • компилируем Си в WASM;

  • встраиваем WASM в JS как Uint8Array;

  • минифицируем uglify-js-ом;

  • сжимаем regpack-ом;

  • встраиваем JS в HTML;

  • перобразуем HTML в data-uri.

Разработка игры

Разработку собственно приложения опущу, она, на самом деле, неплохо подходит под мем с совой.

Диздок игры был реально похож на "рис. 1"
Диздок игры был реально похож на "рис. 1"

Был задуман сверхминималистичный shoot-em-up с направлением полёта вверх, парой врагов и необходимостью обруливать стены. Хитростей там нет, исходники на Гитхабе, в папочке src.

Оптимизация и фичекат

Через пару вечеров я получил наполовину сделанную задумку игры и WASM-файл размером 5868 байт, что после операций инлайна-сжатия-кодирования выдавало data-uri размером где-то 7200 байт. Это очень много: у меня только пол игры готово, а уже нужно отрезать три килобайта из семи. Считай, 50%! Пришлось досрочно перейти к оптимизации.

К этому моменту от изначальной задумки было сделано:

  • генерация стен;

  • отображение растров;

  • кораблик игрока и управление;

  • один тип противника;

  • выстрелы и динамический свет.

Не было сделано:

  • два типа противников;

  • павер-апы;

  • текстуры стен и фона;

  • подсчёт очков.

Единственное «толстое», что можно вырезать, — динамический свет. Вырезано. Противники редуцированы до неподвижных мишеней. Дальше была нудная сессия оптимизаций Си и JS-кода. По итогу удалось сжать data-uri до шести килобайт.

Наибольший эффект дали замена растров на симметричные и отрезание кастомной секции WASM-файла.

С первым понятно:

«Было», «стало», 1/4 от «стало».
«Было», «стало», 1/4 от «стало».

А вот со вторым я попрыгал. Изучая, что ещё отрезать, я обнаружил в конце WASM-файла явно лишнее:

А что это такое в конце WASM-файла?
А что это такое в конце WASM-файла?

Это оказалась custom-секция, которую добавляет clang. Как убедить clang не добавлять custom-секцию, я не нашел, зато нашел готовое решение (small-wasm-trimmer), которое позволяет зачищать WASM-файлы от всякого, включая секции. К сожалению, автор не указал лицензию, но всё равно спасибо ему — ситуативно я сэкономил своё время и 109 байт.

Танцы вокруг base64 и gzip

Дальше урезать фичи уже было некуда, а микрооптимизации давали выигрыш максимум в 20-30 байт. При этом data-uri весил примерно 6100 байт. Нужно было что-то принципиально менять.

По совету коллеги я вернулся к пересмотру способа кодирования WASM и неожиданно преуспел.

Изначально WASM встраивался в JS как массив десятичных чисел:

const Z = new Uint8Array([0,97,115,109,/*...контент файла*/]);

После применялся regpack и кодирование в data-uri вида data:text/html;base64,.

То есть сначала WASM-файл сильно раздувался примерно в 3-4 раза за счёт записи байт в десятичном формате, потом приемлемо сжимался regpack-ом примерно в три раза (алгоритм, убирающий повторы, но менее эффективный, чем gzip или deflate), а потом снова раздувался на треть за счёт base64. Плюс у этого подхода — очень простой и короткий код «распаковки», минус — по факту размер data-uri растёт более чем на треть по сравнению с WASM-файлом, то есть чтобы уложиться в 4096 байт data-uri, нужен WASM менее 3 Кб — нереально.

Я внял коллеге и попробовал сжимать WASM gzip-ом. Сжимается он раза в два, что даёт выигрыш в 2 Кб. Такой объём позволяет удлинить код «распаковки». Разжать gzip в JS можно встроенным классом DecompressionStream.

Получается как-то так:

new Blob([new Uint8Array([31,139,8,0,0,/*...контент файла*/])])
.stream().pipeThrough(new DecompressionStream("gzip"))
.getReader().read().then(
  ({value: Z}) => {
    WebAssembly.instantiate(Z).then(
      ({instance: inst}) => {/*...*/}
    )
  }
)

Вместо десятичных чисел через запятую хотелось бы использовать base64 через atob(), но тогда будет двойное кодирование в base64 при построении data-uri, подумал я... и понял, что base64 в data-uri, так-то, необязательно. Оно, по большому счёту, нужно, чтобы «спрятать» запрещённые для URL символы, которые получаются в результате работы regpack. Если же убрать regpack, то в оставшемся JS-коде после минификации нужно будет только пробелы заэкранировать в %20.

Так как regpack жмёт хуже gzip, то выгодно встроить как можно больше JS-кода в WASM-файл. Здесь мне пришлась к месту директива #embedиз стандарта C23:

const char js[] = {
#embed "boot.js"
    , '\0'
};

Итого финальный пайплайн стал таким:

  • JS-код разделен на «прикладной» и «декодер»;

  • «прикладной» код минифицируется и встраивается в Си-код через #embed;

  • Си-код компилируется в WASM;

  • из WASM вырезается custom-секция;

  • WASM сжимается gzip-ом и встраивается в «декодер»;

  • Декодер минифицируется и из него формируется data-uri без base64.

JS-«декодер» тогда получается таким:

new Blob(U.from(atob('H4sIAAAAAAAAA21...'), c => c.charCodeAt(0))]))
.stream().pipeThrough(new DecompressionStream("gzip"))
.getReader().read().then(
  ({value: Z}) => {
    WebAssembly.instantiate(Z).then(
      ({instance: inst}) => {/*...*/}
    )
  }
)

...а финальный data-uri ужался до 3507 байт. Ничего себе! Это победа! Это ж у меня ещё 500 байт осталось! Это ж дофига!

Откат фичеката

Получив такой нереальный объём памяти, я быстренько докрутил всякого:

  • текстуры стен и фона;

  • подсчёт очков;

  • анимацию взрывов.

Получился WASM-файл в 4893 байта и data-uri в 3979 байт.

Версия 1.0
Версия 1.0

Проект стал более-менее похожим на изначальную задумку, только динамический свет всунуть не удалось и вместо врагов остались мишени. Этот этап я обозначил как v1.0 и пошел писать статью.

Итоговая сборка для тех кто не ходит по ссылкам
data:text/html,<script>U=Uint8Array;new Blob([U.from(atob("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"),c=>c.charCodeAt(0))]).stream().pipeThrough(new DecompressionStream("gzip")).getReader().read().then(({value:Z})=>{WebAssembly.instantiate(Z).then(({instance:inst})=>{let i=inst.exports.js.value,S="",m=new U(inst.exports.memory.buffer);for(;m[i]!==0;i++){S+=String.fromCharCode(m[i])}eval(S)})});</script>

Управление: клик любой кнопкой мыши или нажатие на любую клавишу клавиатуры меняет направление полёта. Выстрелы происходят без участия игрока.

Счёт очков: за пролёт экрана +1 очко, за попадание в мишень +50 очков.

Цель: нужно набрать как можно больше очков до того, как расшибёшься. О стены можно разбиться, о мишени — нет.

Финал

Что я, человек посторонний, смог вынести из опыта замены JS на WebAssembly при разработке микроприложений?

Первое. Это весело.

Второе. Известные мне приёмы оптимизации размера как для JS, так и для Си не подошли, но нашлись новые: regpack выпал из стека, wasm-opt и gzip внесли наибольший вклад в сжатие.

Третье. Хорошо сжимаемые ресурсы (HTML, JS) разумно встраивать в WASM при использовании gzip + DecompressionStream.

Четвёртое. Полезно смотреть в текстовое представление WASM (утилита wasm-dis). Например, выбор поля из структуры по указателю компилируется в неожиданно большие WASM-выражения.

Постскриптум и ссылки

Мне казалось, что обозначенная цель: «Сделать игру, чтобы можно было отправить сообщением, а потом Ctrl-C, Ctrl-V и играй» — не похожа ни на задачи, которые обычно ставят перед собой демосценеры, ни на задачи, для которых изобретался WebAssembly. Но, начав систематизировать ссылки, я понял, что есть широко известные духовно близкие проекты.

Конкурс Js13kGames — написание полноценных игр на JS, которые со всеми зависимостями должны помещаться в zip-архив 13 Кб.
Конкурс прекрасный, и по нему видно, почему 13, а не традиционные для демосцены 1K, 2K или 4К. Минимально функциональное WebGL-приложение с необходимой математикой почти невозможно ужать меньше 3–4 Кб.

Если смотреть со стороны разработки пиксельных игр с ограничениями по экрану, памяти и вводу, то у нас есть сегмент фэнтези-консолей, в том числе основанных на WebAssembly: проекты Wasm4 и MicroW8. Если очень растянуть сову, то выбранные для моего проекта ограничения можно назвать спецификацией фэнтези-консоли: «ввод — единственная кнопка, размер кода — 4K в data-uri, платформа запуска — браузер с HTML5 без интернета».

Код проекта из статьи
https://github.com/ein-gast/wamicro-38

Предыдущий проект автора, с которым сравнивается результат
https://github.com/ein-gast/distance-21

Compiling C to WebAssembly and Running It - without Emscripten
https://depth-first.com/articles/2019/10/16/compiling-c-to-webassembly-and-running-it-without-emscripten/

Компиляция C в WebAssembly без Emscripten
https://habr.com/ru/articles/454868/

Optimizing Compiled WASM code
https://notes.kodekloud.com/docs/Exploring-WebAssembly-WASM/Compiling-to-WebAssembly/Optimizing-Compiled-WASM-code

Утилита regpack
https://github.com/siorki/RegPack

Утилита small-wasm-trimmer
https://github.com/NathanARoss/small-wasm-trimmer

Demo effects using Rust and WebAssembly
https://theor.xyz/rust-wasm-demo-effects/

WebAssembly голыми руками
https://habr.com/ru/articles/901976/

Конкурс Js13kGames
https://js13kgames.com/

Конкурс js13kGames или как написать игру объёмом 13 Кбайт
https://habr.com/ru/articles/840006/

Фэнтази-консоль Wasm4
https://github.com/aduros/wasm4

Фэнтази-консоль MicroW8
https://github.com/exoticorn/microw8