Привет. Я делаю одиночную космическую игру VoidVibe: полёт, бой, торговля, станции, морпехи, абордаж. Работает в браузере (WebGL2) и на Android, оффлайн. Вся игра это один index.html. Внутри нет ни одной картинки, ни одного звукового файла, ни одной библиотеки. Для себя я поставил лимит 512 КБ по gzip на весь файл. Сейчас release-сборка весит 193 КБ из 512, а dev-сборка с панелью разработчика 264 КБ.
Здесь без воды: как устроен генератор, как лежат модели, что делают шейдеры, как синтезируется звук, как собирается файл и где я набил шишки. Код местами урезан, чтобы влезть в статью.
Демо можно потрогать тут: https://void-vibe.com/play (в демо часть режимов отрезана, но галактика, полёт, бой, станции и верфь есть).
Зачем лимит
Лимит нужен не ради рекорда. Если в игре нет ресурсов, то всё в ней вынужденно процедурное: планеты, звёзды, станции, названия, звук. Это даёт бесплатную вселенную: сто систем в галактике, у каждой своя звезда, от одной до пяти планет, кольца, станция, фракция, экономика. И всё это весит ровно столько, сколько весит код, который это считает.
Вторая причина практическая. Игра платная и будет лежать в Google Play. Один файл без внешних зависимостей не ломается от CDN, не требует сервера и открывается из любого места.
Сборка
Собираю esbuild, больше ничего нет. Скрипт tools/build.mjs делает вот что: бандлит src/main.js в один IIFE, минифицирует в release, вшивает CSS и шрифт (WOFF2, base64) и кладёт всё в один dist/index.html. Размер считается по gzip, и если больше 512 КБ, сборка падает с кодом выхода 1. Так что лимит не рекомендация, а красная лампочка.
Три константы подставляются через define (в коде их имена обрамлены двойным подчёркиванием):
DEV вырезает панель разработчика из release целиком (мёртвый код esbuild выкидывает сам)DEMOвключает ограничения демо: потолок денег, ранг, число слотов сохраненияBUILDномер сборки
Отдельный npm-скрипт печатает отчёт размера по модулям. Самое полезное, что я сделал для бюджета: каждый раз видишь, где именно растёт. Вот реальная dev-сборка (JS 1017 КБ до сжатия):
data (все таблицы): 322 КБ, из них оружие и снаряжение 58, баланс 44, товары 32, корабли 29, снаряжение 22
combat: 101 КБ (правила боя и ИИ пиратов)
render: 68 КБ (HUD на 2D-холсте 30, сцена 20, шейдеры 12, модели 5, обёртка GL 2)
world: 33 КБ (генерация систем, астероиды, прилёт)
market: 6 КБ
Обратите внимание на соотношение: данные весят больше всей отрисовки. Это не случайность, об этом ниже.
После минификации и gzip это превращается в 193 КБ. Текст JSON выжимается хорошо, потому что таблицы однотипные. Тексты квестов и диалоги я вообще вынес в отдельные файлы, которые не входят в лимит и грузятся при необходимости.
Зерно: вся вселенная из одной строки
Всё процедурное в игре идёт от генератора. Я взял mulberry32, потому что он умещается в десять строк, быстрый и держит состояние в одном 32-битном числе:
export function hashSeed(str) { let h = 1779033703 ^ str.length; for (let i = 0; i < str.length; i++) { h = Math.imul(h ^ str.charCodeAt(i), 3432918353); h = (h << 13) | (h >>> 19); } h = Math.imul(h ^ (h >>> 16), 2246822507); h = Math.imul(h ^ (h >>> 13), 3266489909); return (h ^ (h >>> 16)) >>> 0; } export function createRng(seed) { let a = (typeof seed === 'string' ? hashSeed(seed) : seed) >>> 0; return { next() { a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }, int(n) { return (this.next() * n) | 0; }, range(lo, hi) { return lo + this.next() * (hi - lo); }, pick(arr) { return arr[this.int(arr.length)]; }, getState() { return a >>> 0; }, setState(s) { a = s >>> 0; }, }; }
Math.imul обязателен: обычное умножение в JS даёт double и теряет младшие биты, а нам нужно честное 32-битное переполнение. getState и setState нужны для сохранений: состояние генератора это одно число.
Главное правило, которое я записал в начало файла: порядок вызовов и алгоритм менять нельзя. Если в генерации системы я добавлю лишний вызов next() до того, как выбирается цвет планеты, то у всех игроков все планеты поменяют цвет, а сохранения, где лежат положения станций и названия, развалятся. Поэтому я делаю так:
для каждого аспекта свой генератор из отдельной строки: seed + решётка + galaxy, seed + решётка + galaxy-attr и так далее. Новый аспект получает новую строку и не трогает старые
если нужно что-то дописать в существующий поток, дописываю только в хвост и только вместе с версией сохранения
Звучит занудно, но это единственное, что спасает от тихой катастрофы. Баги в процедурке самые мерзкие: на моей машине всё нормально, а у игрока планета чуть другого цвета, и никто не поймёт почему.
Галактика и система
Галактика это сто систем. Положения, фракции, уровень цивилизации и экономика считаются из зерна один раз при создании мира (src/world/galaxy.js), выбор взвешенный: у богатых и развитых систем больше шансов стать столицами фракций. Пять галактик заложены данными.
Систему (src/world/system.js) я генерирую уже при входе в неё, а не заранее. На вход зерно и номер системы, на выходе:
звезда одного из пяти типов
от одной до пяти планет, у каждой тип (океан, пустыня, лёд, лава), радиус, орбита, наклон осей, иногда кольца
станция на высоте 2.6-3.0 радиусов планеты над поверхностью
название из слогов, чтобы не хранить список
Первые четыре системы заданы таблицей вручную (Вейра, Тусс, Нодар, Брек): стартовая зона должна быть одинаковой у всех, чтобы можно было писать задания и показывать скриншоты, которые игрок увидит у себя.
Тут важный выбор: планеты не хранят ни одной текстуры, только параметры. Параметры уходят в шейдер, и он рисует планету по формуле. Один и тот же код и для планеты в километре, и для крошечной точки на горизонте.
Модели: корабль в 13 вершинах
Корабли и станции это низкополигональные модели с рёбрами. Формат у них такой, что модель можно читать глазами:
export const TARPAN = { v: [ // вершины, x y z подряд 0, 0, -17, // 0 нос -5, 0.5, -3, // 1 левое плечо 5, 0.5, -3, // 2 правое плечо 0, 4, -4, // 3 верх кабины // ... ], f: [ // грани: [номер палитры, вершины...] [0, 0, 1, 3], [0, 0, 3, 2], [0, 1, 5, 4, 3], // ... ], pal: [ // [цвет заливки, цвет рёбер] [[0.52, 0.58, 0.68], [0.3, 0.85, 1.0]], [[0.3, 0.33, 0.4], [0.25, 0.7, 0.95]], [[0.85, 0.4, 0.2], [1.0, 0.6, 0.25]], ], };
Стартовый корабль Тарпан это 13 вершин и 12 граней. Нос смотрит вдоль минус z. В таком формате без редактора легко рисовать корабли в голове: симметрию левый-правый я не храню, пишу обе стороны явно, потому что в данных это дешевле (сжимается), чем код отражения с исключениями.
Часть моделей генерируется функциями. Например, hull строит корпус из сечений (положение, ширина, высота) и потом навешивает детали. Так сделаны остальные из десяти кораблей, и число вершин остаётся небольшим.
Функция buildModel превращает таблицу в то, что нужно GPU:
Многоугольники режет веером на треугольники.
Считает плоские нормали по первой тройке вершин грани. Нормаль нулевая значит грань не освещается (у меня это флаг unlit в палитре, им я рисую огни и свечения).
Собирает уникальные рёбра. Ключ ребра это пара индексов (меньший, больший), чтобы общее ребро двух граней не нарисовалось дважды.
Вся модель это один буфер вершин, один буфер индексов и один буфер рёбер.
Рендер: рёбра как светящиеся линии
Игра нарисована в стиле векторного неона: тёмные грани и яркие линии по краям. Тут есть техническая подробность, на которой я потратил больше всего времени.
Нативные линии WebGL бесполезны: ширина всегда 1 пиксель, на телефоне с высокой плотностью их почти не видно. Поэтому каждое ребро это инстанс. Один инстанс = один отрезок A-B с цветом, а геометрия инстанса это четыре вершины прямоугольника (aC). Вершинный шейдер берёт оба конца, переводит в пространство камеры, проецирует и разводит углы в экранном пространстве так, чтобы ширина линии была постоянной в пикселях (uW) вне зависимости от расстояния. Если оба конца за ближней плоскостью, отрезок выбрасывается за экран.
В результате линия корабля в километре и корабля под носом одинаково чёткая, и это даёт тот самый вид. Плюс бесплатно получаются огни и лазеры: это те же отрезки с другим цветом.
Грани рисуются отдельным проходом, освещение двустороннее по gl_FrontFacing (у плоских моделей без внутренних объёмов проще не следить за направлением обхода).
Планеты: шум без текстур
Планета это сфера, а весь вид считает фрагментный шейдер. Основа это обычный value noise по трёхмерной точке и fbm (сумма октав):
float hash(vec3 p){p=fract(p*0.3183099+.1);p*=17.;return fract(p.x*p.y*p.z*(p.x+p.y+p.z));} float noise(vec3 x){vec3 i=floor(x),f=fract(x);f=f*f*(3.-2.*f); return mix(mix(mix(hash(i),hash(i+vec3(1,0,0)),f.x),mix(hash(i+vec3(0,1,0)),hash(i+vec3(1,1,0)),f.x),f.y), mix(mix(hash(i+vec3(0,0,1)),hash(i+vec3(1,0,1)),f.x),mix(hash(i+vec3(0,1,1)),hash(i+vec3(1,1,1)),f.x),f.y),f.z);}
Три измерения нужны, чтобы не было швов и растяжения у полюсов: точка берётся на единичной сфере (normalize от объектных координат, а не от интерполированной позиции на гранях сетки, иначе на низкополигональной сфере видны радиальные лучи у полюса).
Дальше простое: высота h = fbm. Если h ниже уровня моря, цвет смешивается между глубокой и мелкой водой, иначе между двумя цветами суши. Полярные шапки по |y| с поправкой на высоту. Облака отдельный fbm со сдвигом по времени. Атмосфера это френелево свечение по краю. Вся планета это небольшой кусок шейдера, а меняется только набор uniform из зерна.
Дробные октавы
Вот тут была самая неприятная проблема. Планету видно с расстояния от тысячи километров до пары сотен метров от поверхности. Фиксированное число октав не работает: далеко мелкие октавы мельче пикселя и дают мерцание (алиасинг), близко детали пропадают. Решение очевидное: число октав зависит от расстояния. Но если оно целое, то при смене береговая линия прыгает: добавилась октава, и вся береговая линия изменилась. Игрок летит к планете и видит, как материки дёргаются.
Поэтому число октав дробное. Октава i входит с весом clamp(of - i, 0, 1), и пока вес не дошёл до единицы, вместо недостающей октавы подставляется её среднее значение 0.5:
float fbmF(vec3 p,float of){ float a=.5,s=0.; for(int i=0;i<12;i++){ float w=clamp(of-float(i),0.,1.); s+=a*(w>0.?mix(.5,noise(p),w):.5); p=p*2.02+vec3(1.7,9.2,3.3); a*=.5; } return s; }
Новая октава плавно проявляется из серого, а не врывается. Цикл остаётся фиксированной длины в 12.
Сколько октав нужно на данный пиксель, я считаю из размера пикселя на поверхности: через fwidth(p * 2.6) получается производная положения точки на экране, и число октав определяется как log2(0.25 / fw) с запасом в октаву до предела Найквиста. Получается самонастраивающаяся детализация: ничего не зависит от того, на каком расстоянии камера, и никакой таблицы уровней детализации.
В итоге планета подлетом выглядит так, как будто детализация бесконечная, потому что на самом деле она каждый кадр считается заново до уровня пикселя.
Звезда, кольца, ЭМИ
Звезда это тот же шум с огнём вместо воды плюс аддитивное свечение (квадрат, повёрнутый к камере). Кольца планет это плоский диск с радиальным шумом и тенью планеты. Оболочка ЭМИ-вспышки это сфера с анимированным шумом. Всё одним приёмом.
Полноэкранные эффекты (небо) рисуются одним треугольником без буферов: gl_VertexID выбирает вершину.
Звук: рецепты вместо файлов
Звуковых файлов в игре нет. Каждый звук это таблица слоёв, а синтезатор строит из неё граф WebAudio в момент проигрывания. Слоёв два типа: осциллятор и фильтрованный шум.
// осциллятор: форма, частота (число или [от, до] экспонента), длительность, громкость const O = (w, f, d, g, x = {}) => ({ k: 'o', w, f, d, g, ...x }); // шум: тип фильтра, частота, длительность, громкость const N = (ft, f, d, g, x = {}) => ({ k: 'n', ft, f, d, g, ...x }); // запуск двигателя: толчок зажигания, рокот с подъёмом оборотов, шипение газа const ENGINE_UP = [ O('sine', [70, 38], 0.14, 0.7), N('lowpass', [500, 120], 0.1, 0.5), O('sawtooth', [42, 200], 0.95, 0.45, { a: 0.12, lp: [180, 1500] }), O('square', [84, 400], 0.8, 0.12, { t: 0.12, lp: 900 }), ];
Параметры слоя: t задержка начала, a атака, h удержание, lp фильтр низких частот, который тоже может скользить. Параметр f в виде пары значит экспоненциальную развёртку частоты за время звучания (exponentialRampToValueAtTime), и из неё делается почти всё: лазер это пила с падающей частотой, взрыв это шум с падающим фильтром плюс низкий синус, отключение двигателя это пила с падением от 320 до 45 Гц.
Сам синтез короткий. Для каждого слоя создаётся усилитель с огибающей (быстрая атака, удержание, экспоненциальное затухание), источник (осциллятор или циклический буфер шума), опционально фильтр:
env.gain.setValueAtTime(0.001, ts); env.gain.linearRampToValueAtTime(L.g, ts + a); if (L.h) env.gain.setValueAtTime(L.g, ts + a + L.h); env.gain.exponentialRampToValueAtTime(0.001, ts + d);
Почему 0.001, а не ноль: экспоненциальная развёртка не умеет приходить в ноль, это падает с исключением.
Что ещё сделано, чтобы это звучало прилично:
jit: небольшой случайный разброс высоты на каждое проигрывание. Без него очередь из десяти выстрелов звучит как штамп, а с ним как стрельба.
Дальность: у звука есть ref, расстояние, на котором он вдвое тише. Позиция источника переводится в громкость и панораму. Есть и gap, минимальный интервал между повторами одного звука, чтобы очередь не превращалась в треск.
Звук из-за спины приглушается фильтром 2400 Гц. Это мелочь, но уши сразу понимают, где враг.
Лимит голосов 28, очередь событий 48 за кадр. Остальное отбрасывается, кроме звуков с приоритетом (тревоги, попадания по игроку). Без этого лимита на телефоне в большом бою звук рассыпается в кашу.
Зацикленные звуки (форсаж, заправка, круиз) идут отдельной шиной loop, и игра просто выставляет им уровень каждый кадр, а включением и выключением занимается синтезатор.
Всего в таблице больше шестидесяти звуков. Рецепт обычно занимает от одной до пяти строк. Для подбора я сделал отдельную страницу sounds.html (в dev-сборке и на тестовых стендах), где каждый звук можно прослушать кнопкой, и именно на ней проходит большая часть настройки. Подгонка на слух: двигаешь число, жмёшь кнопку.
Данные как таблицы
Самое большое по размеру в игре это данные. Все числа баланса, товары, корабли, оружие, снаряжение, цены, рецепты звуков лежат в таблицах в каталоге data. Код игры читает их и не содержит чисел. Причины:
Баланс правится без поиска по коду. Меняю цифру в таблице, пересобираю, играю.
Таблицы отлично сжимаются: однотипные строки, повторяющиеся ключи.
Юнит-тесты (npm test, чистая логика без отрисовки) могут проверять инварианты на самих таблицах: у каждого товара есть цена, у каждого корабля есть модель.
Например, рынок: 255 товаров, цены станции считаются из зерна, поверх них накладываются события (дефицит, излишек) по сроку из зерна и системе, а то, что игрок уже купил и продал, лежит отдельным маленьким состоянием в сохранении и со временем затухает. Игрок видит разные цены в разных системах, но они воспроизводимы.
Ограничение, о которое легко споткнуться: когда в таблицах нужно описать поведение (например, как большой корабль теряет башни), так и тянет писать в данные мини-язык. Правило такое: данные это числа и имена, поведение это код.
Что пошло не так
Самая неприятная ошибка за всё время: планеты и звёзды мельтешили. Радиальные лучи у полюса, блочные пятна, дрожь при движении камеры, а сквозь планету просвечивала звезда. Подозрение сначала падало на шум и на качество сетки. Проверка: вывел в шейдере просто координату p*0.5+0.5 вместо цвета и увидел те же пятна поверх гладкого градиента. Значит шум ни при чём.
Причина оказалась в буфере глубины. Камера с near 1 м и far 1e9 м даёт на расстоянии 11 тысяч км шаг глубины около 7 тысяч км (24 бита), а на 36 тысячах км уже около 78 тысяч км. Радиус планеты 5 тысяч км, то есть меньше шага. Задняя половина шара спорила за глубину с передней, потому что сфера рисовалась без отсечения граней.
Решение: небесные тела рисуются от дальнего к ближнему, каждое поверх предыдущих (depthFunc ALWAYS), грани обращены наружу с отсечением задних. Свечение звезды рисуется сразу после диска звезды, чтобы ближние тела и корабли его закрывали. Кольцо планеты рисуется в два прохода вдоль луча: дальняя половина до планеты, ближняя после. Корабли и станции остались по глубине, у них расстояния километры и шага хватает. far камеры при этом поднял до 1e10.
Тогда же переделал октавы на дробные (раньше их число прыгало целыми ступенями, и береговые линии дёргались при приближении). В тот же заход убрал медленно ползущую прокрутку шума звезды (скорость 0.03 поменял на 0.006) и опустил яркость: голубая звезда раньше была сплошным белым диском, а после правки стал виден шум и цвет.
Вторая вещь: сохранения и генератор. Из-за правила про порядок вызовов у меня для каждого аспекта свой генератор из отдельной строки и версия сохранения. Это не история про катастрофу, а правило, которое я записал заранее, потому что знаю, как такие баги выглядят.
Третья вещь, про масштабы. Мир получился слишком мелким, и корабли и станции я потом увеличил, добавил круиз (форсаж, предел скорости растёт с высотой над телом) и сделал звезду размером с настоящее солнце. До звезды теперь 128-201 тысяч км, и пришлось делать автопилот с точкой остановки, иначе путь занимал бы вечность.
Что дальше
Сейчас делаю абордаж: захват вражеского корабля с высадкой морпехов. Дальше большие корабли с башнями, которые можно обезоруживать, и чужие.
Если интересно, следите здесь или в Telegram: https://t.me/voidvibe\_game. Демо: https://void-vibe.com/play. Про любое место из статьи могу рассказать подробнее. Особенно интересно услышать от тех, кто делал процедурные планеты: как вы решаете переход между уровнями детализации?
