Космическая игра в одном HTML-файле: процедурная галактика, планеты из шума и звук без единого семпла

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

Привет. Я делаю одиночную космическую игру 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:

  1. Многоугольники режет веером на треугольники.

  2. Считает плоские нормали по первой тройке вершин грани. Нормаль нулевая значит грань не освещается (у меня это флаг unlit в палитре, им я рисую огни и свечения).

  3. Собирает уникальные рёбра. Ключ ребра это пара индексов (меньший, больший), чтобы общее ребро двух граней не нарисовалось дважды.

Вся модель это один буфер вершин, один буфер индексов и один буфер рёбер.

Рендер: рёбра как светящиеся линии

Игра нарисована в стиле векторного неона: тёмные грани и яркие линии по краям. Тут есть техническая подробность, на которой я потратил больше всего времени.

Нативные линии 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. Код игры читает их и не содержит чисел. Причины:

  1. Баланс правится без поиска по коду. Меняю цифру в таблице, пересобираю, играю.

  2. Таблицы отлично сжимаются: однотипные строки, повторяющиеся ключи.

  3. Юнит-тесты (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. Про любое место из статьи могу рассказать подробнее. Особенно интересно услышать от тех, кто делал процедурные планеты: как вы решаете переход между уровнями детализации?

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


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

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

Создадим прототип SPA в Bitrix без использования популярных JS фреймворков и библиотек, все будет сделано средставами Bitrix.И самое главное без потери серверного рендеринга и SEO страниц.ПогналиВсего...
Премия «Оскар» в номинации «лучшая залепуха от генеративного ИИ» однозначно достанется Gemini, ИИ-решению от Google. Кстати, после этого конфуза работа генератора изображений Gemini была приостановлен...
Делимся своим опытом о том, как мы собираем отчёты по релизам – быстро, корректно и без ручного труда.Мы в True Engineering начали автоматизировать подготовку Release Notes несколько лет ...
Нередко при работе с Bitrix24 REST API возникает необходимость быстро получить содержимое определенных полей всех элементов какого-то списка (например, лидов). Традиционн...
Много всякого сыпется в мой ящик, в том числе и от Битрикса (справедливости ради стоит отметить, что я когда-то регистрировался на их сайте). Но вот мне надоели эти письма и я решил отписатьс...