CNN TürkUygun Fiyata Dengeli ve Akıllı PerformansInquirerTyphoon Rosal exits PAR on Saturday morning (October 24)PunchADC slams APC over deepfake allegation, demands evidenceThe Jerusalem PostIsrael's opposition weighs minority coalition as Bennett, Liberman reject Arab supportBollywood HungamaSubhash Ghai reunites with Shreyas Talpade 20 years after Iqbal for Manya as filmmaker marks 50 years in cinema한겨레단 하루도 익숙해 지지 않은 이태원 참사 4년…“복수 아닌 정의 원해”ZDF heuteEntdecken Sie das ZDF-NachrichtenstudioStraits Times Sport‘Weeks, not days’ in Vincent Kompany recovery, say Bayern Munichسكاي نيوز عربيةأيهما أفضل لصحتك.. الفلفل الأحمر أم الأصفر؟Il Fatto Quotidiano“Sono grasso e cecato”, ma musicalmente eccezionale: “Je so’ pazzo” è il ritratto verace di Pino DanielekickerLIVE! Hertha beginnt mit Pedersen - Zwei Wechsel bei Magdeburg7sur7Le code de la route devient plus sévère en cas d’infraction à une interdiction de conduire
The Daily Newsstand · Free, Always
Saturday, October 24, 2026

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

Translate

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

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.