Привет, Хабр. Последний год я делаю ритм-рогалик на гексах AsterMaster (Godot 4.6). Бесплатное демо уже лежит в Steam, а на сайте игры те же модели крутятся прямо в браузере:

  • Steam: store.steampowered.com/app/5095060 — демо бесплатное, 19–26 октября игра участвует в Steam Next Fest, релиз 17 ноября.

  • Сайт: aster-master.com — трейлер, бестиарий на сплатах, пресс-кит.

В игре есть одна вещь, про которую меня чаще всего спрашивают в комментариях: корпус дрона игрока и боссов — не меш. Это облако из ~6000 гауссиан, которое выучено по рендерам из Blender, упаковано в полмегабайта и рисуется в Godot одним MultiMesh. И именно поэтому его можно мять, дырявить, рвать на куски и собирать обратно — целиком в вершинном шейдере, без участия CPU.

Сплат-корпус дрона после серии попаданий: кратеры, вмятое нутро, горячие осколки. Стендовый кадр, в игре всё это живёт на 72 пикселях.

Статья про технику: зачем вообще сплаты в игре, где персонаж занимает сотню пикселей; как выглядит пайплайн «Blender → gsplat → свой бинарный формат → Godot Compatibility»; что в нём пришлось придумать самому (запечённые каналы, энтропийная бинаризация, чистка флоатеров, формат с 64 предрассчитанными сортировками); как на этом строится деструкция; и что не взлетело — с цифрами. Пайплайн делался под телефоны (Compatibility-рендерер, half-float, никакой сортировки в рантайме), а в итоге живёт в Steam и на Deck — ограничения оказались полезными.

Сразу оговорка о масштабе: это не статья «как сделать фотореалистичный 3DGS-сканер». Это рассказ, как взять метод из соседней области и использовать его совсем не по назначению.

Зачем сплаты в игре, где всё 100 пикселей

Рисовать я не умею, поэтому модели врагов и дрона — генеративные (Meshy). Выходит симпатично, но по 25–55 МБ хайполи на модель, с сотнями незамкнутых «пластинок» вместо честной топологии. В билд такое не кладёшь, а главное — меш скучно умирает: проиграл анимацию, растворился, исчез. Мне же хотелось, чтобы бой читался по корпусу: мятый борт — это история, полоска HP — это отчёт.

Gaussian Splatting (3DGS) — это представление сцены облаком анизотропных гауссиан: у каждой позиция, ковариация (масштаб + поворот), цвет и прозрачность. Облако обучают дифференцируемой растеризацией по набору фотографий с известными позами камер, и обычно это инструмент для фотореалистичных сканов реальности. Я посмотрел на это с другой стороны: каждый сплат — отдельный инстанс со своей позицией, цветом и размером. Это готовая система частиц в форме модели. Её можно сдвигать, вращать группами, выбивать по одной и возвращать на место — и всё это уже умеет вершинный шейдер.

Отсюда и требования, которые перевернули обычный 3DGS-пайплайн:

  • Стиль — toon, а не фотореал. Значит, учимся не по фотографиям, а по синтетическим рендерам с точными позами. COLMAP не нужен.

  • Бюджет — жёсткий. Не «сколько получится», а ровно N сплатов на модель: 6000 на босса, пара тысяч на мелочь.

  • Рантайм — мобильный GPU. Compatibility-рендерер Godot, пер-инстансные данные в half-float, прозрачность — враг. Нужно, чтобы почти все сплаты были непрозрачными и рисовались без сортировки.

  • Нужны лишние каналы. Нормали (для cel-света и вспышек), принадлежность к части модели, «глубина» внутри корпуса — всего этого у ванильных сплатов нет.

День первый: пять подходов за сутки

Исследование уложилось в один длинный день, и арка получилась показательная.

Три представления одного дрона: splatfacto на toon-рендерах (16 тыс. гауссиан), собственный растеризатор суперэллипсоидов (500 примитивов), gsplat с MCMC-бюджетом (6000).

  1. splatfacto (nerfstudio) на PBR-рендерах — 41 тысяча гауссиан, фотореал. Красиво, бесполезно: никакой выгоды против меша, а в игре всё равно нужен toon.

  2. splatfacto на cel-рендерах — 16 тысяч. Уже стиль, но бюджет не таргетируется: эвристика adaptive density control просто останавливается, где ей удобно. Для одной модели на экране ок, для босса с эффектами и толпой — нет.

  3. Свой дифференцируемый растеризатор суперэллипсоидов. Идея: примитив с честной резкой гранью F = (Σ|q|^p)^(1/p), морфящийся от октаэдра до скруглённого бокса, с аналитическими нормалями и «панельной» семантикой. Получился очаровательный дрон из 500 примитивов весом 32 КБ, который сходится — но «кистью», без деталей. Эта ветка и дала имя папке проекта (sdf_splat), и о ней будет отдельный пост-мортем ниже.

  4. Сурфели с меша — семплируем поверхность, кладём жёсткие квадраты в касательной плоскости, цвет из текстуры. Никакого обучения. Отвергнуто за час: hard-edge мозаика читается как грязь, сурфели не блендятся.

  5. Победитель — мини-трейнер поверх gsplat с MCMCStrategy(cap_max=N): жёсткий бюджет, 6000 сплатов, 21 секунда обучения на RTX 3060, лосс вдвое лучше, чем у кастомного растеризатора.

SDF-ветка от v1 до v3: 500 суперэллипсоидов «кистью», изо-датасет, попытка добрать деталь «стикерами»-пластинками.

Ключевые решения того дня дожили до релиза без изменений:

  • обучение на синтетических Blender-рендерах с известными позами; стиль задаётся данными (cel-шейдер в Blender), сферических гармоник нет — только прямой RGB;

  • нормали не учатся, а снапаются с исходного меша после тренировки — отсюда в игре есть динамический cel-релайт и вспышки от выстрелов;

  • контур не учится — он screen-space поверх;

  • рендер в Godot: непрозрачное ядро без сортировки + тонкая полупрозрачная «шуба» с предрассчитанными порядками.

Датасет: Blender headless вместо фотографий

Датасет рендерит скрипт для headless Blender: импорт GLB, нормализация модели в куб 1.6, камера на орбите радиуса 3.2, 1024² с альфой, формат transforms_*.json как у NeRF-синтетики.

Кадры двух датасетов первого дня: PBR-рендер и cel-рендер того же дрона. Сплат учит ровно то, что видит, включая свет и стиль.

R = 3.2
def pose(split, i, n):
    golden = math.pi * (3 - math.sqrt(5))
    if split == 'train':
        t = (i + 0.5) / n
        smin, smax = math.sin(math.radians(BELT_MIN)), math.sin(math.radians(50))
        elev = math.asin(smin + (smax - smin) * t)   # равномерно по sin — равномерно по площади
        az = i * golden                               # золотая спираль по азимуту
    ...
    return Vector((R * cos(az) * cos(elev), R * sin(az) * cos(elev), R * sin(elev)))

Два момента, которые отличают игровой датасет от «обойти объект по кругу»:

Пояс элевации. Игровая камера смотрит под 32–50° сверху, и модель только крутится по yaw. Значит, днище не видно никогда, а всё, что между 32° и 50°, должно быть покрыто плотно. Train-виды лежат золотой спиралью в этом поясе, val/test — на фиксированных элевациях. Позже пояс пришлось расширить вниз до 14° — об этом в разделе про флоатеры.

Свет. Сплат учит то, что видит, включая свет. Я прогнал три варианта материалов для одного дрона: cel (toon-шейдер прямо в Blender), flat (чистое альбедо без света) и ao (диффуз под белым куполом без солнца).

Три датасета одного дрона и что из них выучилось (один ракурс, один бюджет 6000). cel: toon-шейдер уже в Blender, полосы света запекаются в цвет сплатов. flat: альбедо без света, объём теряется. ao: мягкое затенение от купола, объём есть, направленных полос нет.

То же в игре, поверх рантайм-cel-шейдера: cel-датасет даёт двойные полосы, flat — плоский блин, ao сохраняет объём и не спорит с рантайм-светом.

Победил ao: запечённое затенение от купола даёт глубину, но без направленных полос, которые двоились бы с cel-бэндами рантайма. Этот же рецепт потом без изменений раскатался на всех мобов и боссов: каждый материал с текстурой пересобирается в diffuse(texture × HSV).

Грабли датасета, которые стоили по вечеру каждые:

  • У некоторых моделей первой TEX_IMAGE-нодой в материале оказалась тёмная ORM-карта — датасет вышел чёрным. Правильный путь: от Principled BSDF идти по входу Base Color вглубь до текстуры, с фолбэком на первую sRGB-картинку.

  • «Боссам не хватает деталей». Я грешил на бюджет сплатов и на тренер. Корень оказался в том, что датасеты рендерились с GLB из репозитория игры, где текстуры давно ужаты до 128² старой оптимизацией. Полные сорсы 2048² лежали в соседней папке. Мыло на входе — мыло в бинах.

Один и тот же босс: слева — старый ран (датасет с текстур 128², 4500 сплатов), справа — ретрейн с полного сорса (2048², 6000). Снизу центр лица крупнее. Бюджет тоже вырос, но деталь дал не он: на 128² тренер просто не видел панельных линий и зубов.

Тренер: 150 строк поверх gsplat

Весь тренер — один файл. Параметры как в оригинальном 3DGS, но цвет — прямой RGB через сигмоиду, без сферических гармоник. Инициализация — семплы с поверхности исходного меша с цветом из текстуры (у trimesh это одна строка).

params = torch.nn.ParameterDict({
    'means':     torch.nn.Parameter(pos),
    'quats':     torch.nn.Parameter(q + torch.randn_like(q) * 0.01),
    'scales':    torch.nn.Parameter(torch.full((N, 3), math.log(0.03))),
    'opacities': torch.nn.Parameter(torch.zeros(N)),
    'colors':    torch.nn.Parameter(torch.logit(col.clamp(0.02, 0.98))),
})
strategy = MCMCStrategy(cap_max=N, refine_start_iter=500,
                        refine_stop_iter=steps - 1000, refine_every=100)

for step in range(steps):
    vi = np.random.randint(n_views)
    # случайный серый фон на каждом шаге — анти-флоатер
    bg_val = float(np.random.random())
    rgb, alpha, info = rasterization(means, quats, scales.exp(), sigmoid(opacities),
                                     sigmoid(colors), viewmats[vi][None], K[None],
                                     width=res, height=res,
                                     backgrounds=torch.full((1, 3), bg_val))
    gt = imgs[vi] + (1.0 - alphas_gt[vi])[..., None] * bg_val
    loss = (rgb - gt).abs().mean() + 0.3 * (alpha - alphas_gt[vi]).abs().mean()
    if entropy > 0:   # поздний annealing: форма должна устаканиться до бинаризации
        ent_w = entropy * max(0.0, (step / steps - 0.6) / 0.4)
        o = torch.sigmoid(params['opacities'])
        loss = loss + ent_w * (o * (1 - o)).mean()
    loss.backward()
    strategy.step_post_backward(params, optimizers, state, step, info, lr=lr_means)
    for opt in optimizers.values():
        opt.step()

Ход тренировки босса: шаг 0 — семплы с меша, шаг 500 — силуэт схвачен, шаг 6000 — финал. Весь ран — 22 секунды.

Три приёма, которые сделали результат играбельным:

MCMC вместо клонирования. MCMCStrategy из gsplat не размножает гауссианы, а перемещает «мёртвые» (с низкой прозрачностью) на место «живых» — число сплатов никогда не превышает cap_max. Бюджет становится входным параметром, а не результатом.

Случайный серый фон. Первый тренер композитил над чёрным и получал «тени-прокси»: тёмный сплат, повешенный в пустоте под пушкой, почти ничего не стоит по лоссу (RGB-ошибка ≈ 0, остаётся слабый альфа-штраф) и чуть-чуть помогает затенить корпус. Над случайным фоном любой вне-силуэтный сплат дорог по RGB в каждом ракурсе. Вне силуэта осталось ~25 сплатов вместо ~40; добить до нуля тренер не может — на 400 px суб-пиксельная точка в лоссе почти бесплатна при любом фоне.

Энтропийный штраф на прозрачность. o·(1−o) с поздним annealing (последние 40% шагов) выдавливает прозрачности к 0 или 1. В конце ~98% сплатов полностью непрозрачны. Это не эстетика — это ключ к рендеру без сортировки, о котором ниже. Если включить штраф с самого начала, он воюет с альфа-лоссом на мягких краях силуэта и форма «не устаканивается».

После тренировки — post-hoc snap нормалей: для центра каждого сплата ищется ближайшая точка на нормализованном меше (trimesh.proximity.ProximityQuery), нормаль берётся с фейса. Нормали не участвуют в лоссе и ничего не стоят тренировке, но дают в игре честный cel-свет и вспышки.

Цифры: 6000 шагов при 400² — 21–22 секунды на RTX 3060, лосс 0.003–0.011 в зависимости от модели. Ретрейн всех пяти боссов батчем — три минуты. Это то, что позволило перепробовать датасеты, бюджеты и штрафы десятками: цикл «поменял параметр — посмотрел» короче, чем кофе.

Два босса из демо по четыре ракурса, отрендеренные прямо из обученных облаков по 6000 сплатов — «смотрины» перед экспортом в игру (остальных не показываю: спойлеры). Плоское альбедо под купольным светом; cel-свет, вспышки и контур добавит рантайм.

Экспорт: что ещё можно запечь, если у тебя есть исходный меш

Экспорт — самый толстый скрипт пайплайна, и это не случайно. У обычного 3DGS из «истины» есть только фотографии. У меня есть сам исходный меш — а значит, в сплаты можно запечь всё, что можно посчитать по мешу.

Ядро и шуба

Сплаты делятся по прозрачности: opacity > 0.9 — ядро (core), остальное — шуба (shell). После энтропийного штрафа шуба — это ~200 сплатов из 6000.

Ядро рисуется как непрозрачные октагоны в MultiMesh с обычным z-буфером, без сортировки и без discard. В первой версии ядро было квадами с гауссовым спадом и alpha scissor — 86% площади квада уходило в discard, а на тайловых GPU discard ломает early-Z. Решение: на экспорте посчитать радиус видимого диска из прозрачности и запечь его прямо в оси инстанса:

# Старый пайплайн: квад на 6σ, alpha = op·exp(−4.5 r²), scissor 0.45
# → видимый диск r̂ = sqrt(ln(op/0.5)/4.5) от полуразмера квада (~14% площади).
# Новый: октагон под этот диск (+20% запаса), никакого discard.
r_hat = np.sqrt(np.log(np.maximum(op[core], 0.55) / 0.5) / 4.5)
ax0[core] *= (r_hat * 1.2)[:, None]
ax1[core] *= (r_hat * 1.2)[:, None]

Шуба рисуется с alpha-blend и гауссовым спадом во фрагменте — ей нужна сортировка back-to-front. Здесь помогает свойство игры: камера зафиксирована по наклону, модель только крутится вокруг вертикали. Значит, порядок отрисовки зависит от одного числа — heading’а. На экспорте считаются 64 порядка сортировки (по одному на сектор в 5.6°), в рантайме при смене сектора буфер MultiMesh переупорядочивается копированием. Никакой сортировки на лету.

Побочный эффект непрозрачного ядра: стал осмысленным MSAA — он сглаживает стыки между октагонами и лечит шум при покачивании модели.

Belly cull и порядок важности

Раз пояс камеры известен (32–50°), сплат, чья нормаль никогда не смотрит ни в одно направление пояса, не виден никогда. Это днище, и его можно выкинуть навсегда: минус ~10% сплатов. Оставшееся ядро сортируется по важности (прозрачность × площадь двух главных осей), чтобы LOD в рантайме был одной строкой: visible_instance_count режет хвост мелочи.

Четыре запечённых канала

В Godot у инстанса MultiMesh есть трансформ, цвет и один custom_data vec4. Трансформ несёт оси сплата (две главные оси ковариации, нормаль — третья), цвет — альбедо и прозрачность. В custom_data запекаются четыре числа, которые стали топливом всех эффектов:

Канал

Откуда

Зачем

part 0…7

k-means по островам меша (острова не рвутся) или ручная разметка семантических частей

жёсткая анимация групп, пер-частный урон, неразрушаемые пушки

emis 0…1

эвристика по обученному цвету: насыщенный + яркий + редкий относительно медианного цвета модели

лампочки, шевроны, линзы — то, что светится и гаснет с износом

ao 0…1

косинусная полусфера из 24 лучей по мешу (embree)

маска «глубины»: свет из щелей при заряде, нутро при разломах

edge 0…1

выпуклые vertex defects меша

кромки панелей, износ

Про «редкость» в эмиссии — отдельный урок. Первая эвристика «насыщенно + ярко» зажгла насыщенно-красного босса целиком: 2572 светящихся сплата из 4200. Правильная семантика акцента — цвет, далёкий от медианного цвета корпуса. После добавления rarity осталось 877: очки, молнии, челюсть.

Ремонт winding’а — история одного бага

После перевода боссов на сплаты один из них при уроне начал «вылезать сплатами наружу»: кратер должен вдавливать сплаты внутрь вдоль нормали, а они выталкивались. Диагноз: GLB из генератора собраны из сотен мелких незамкнутых компонент-«пластинок» (у Crimson Skull их 558), и у ~40% пластинок обход вершин ориентирован внутрь. Post-hoc snap наследовал перевёрнутые нормали, а от них зависело всё: cel-бэнд, AO-лучи (стреляли в толщу), belly cull, направление кратеров.

trimesh.repair.fix_normals здесь бессилен — объёмное голосование не работает на открытых оболочках. Свой ремонт занял двадцать строк: для каждой компоненты площадно-взвешенное голосование dot(normal, center − centroid), если сумма отрицательна — переворачиваем winding всей пластинки; затем нормали сплатов флипуются по ближайшему уже честному фейсу. У черепа перевернулось 56% сплатов, у другого босса 45%.

comps = trimesh.graph.connected_components(mesh.face_adjacency, min_len=1,
                                           nodes=np.arange(len(mesh.faces)))
fc, fn, fa = mesh.triangles_center - mesh.centroid, mesh.face_normals, mesh.area_faces
faces = mesh.faces.copy()
for cf in comps:
    if np.sum(np.einsum('ij,ij->i', fn[cf], fc[cf]) * fa[cf]) < 0.0:
        faces[cf] = faces[cf][:, ::-1]        # пластинка смотрит внутрь — переворачиваем
mesh = trimesh.Trimesh(mesh.vertices, faces, process=False)

Чистка флоатеров: три признака и один урок

Флоатер — сплат, висящий в воздухе рядом с моделью. У целой модели их не видно на чёрном стенде; в игре на сером полу арены они читаются как мусор, а при деструкции их становится больше. Выяснилось, что у флоатера три независимых признака, и ловить нужно все три:

  1. Далеко от меша. Дистанция до поверхности p50/p99 у здорового облака — 0.013/0.057 (в единицах модели 1.6); всё дальше 0.055 — мусор.

  2. Оторван. Граф соседства по grid-hash; живёт только главная компонента. У прод-аватара нашлись кластеры-призраки по 22–24 сплата, висевшие рядом с корпусом.

  3. Торчит из силуэта. Луч игрового ракурса через центр сплата проходит мимо меша. Сетка ракурсов должна быть плотной — 72 азимута × 5 элеваций: флоатер живёт в секторе уже 5°, а 12 азимутов пропускали три четверти висяков.

Плюс четвёртый проход по самому датасету: проекция сплата в каждый из 200 train-видов; если диск целиком лежит на прозрачном фоне — это флоатер, который тренер не убил, потому что он суб-пиксельный.

Один и тот же кадр стенда на чёрном и на сером фоне. Инструментальный урок: тёмные флоатеры невидимы на чёрном — все прошлые «рендер-судимо чисто» смотрели сквозь них. Силуэт теперь судится только на сером.

Самый действенный фикс оказался не в экспорте, а в датасете: расширить пояс камер вниз с 32° до 14° и увеличить число train-видов со 120 до 200 (чтобы плотность в игровом поясе не разжижилась). Низкие скользящие виды дают тренеру супервизию силуэта снизу: сплат под стволом торчит на пустом фоне, и лосс его убивает сам. Торчащих из силуэта сплатов: 18 до ретрейна → 9 после → 7 после силуэт-cull’а; остаток — легитимные кромки тонких стволов.

Формат SPL2

На выходе — JSON для отладки и бинарный .bin: магия SPL2, три int32 (n_core, n_shell, n_buckets), затем для ядра и шубы блоки float32 pos / ax0 / ax1 / normal / color / opacity / attrs, затем 64 порядка сортировки шубы int32, затем опциональный хвост CHNK (материалы и куски — ниже). Рядом — meta-сайдкар: масштаб и смещение нормализации относительно исходного GLB (чтобы узел-сплат вставал в сцену ровно на место меша), кончики стволов, ось пушек, бокс двигателя.

Вес: дрон игрока — 5056 сплатов ядра + 203 шубы ≈ 0.5 МБ; боссы — по ~0.4 МБ. Исходные GLB — 25–55 МБ.

Рендер в Godot: MultiMesh, Compatibility и три грабли

Сплат-модель — обычный Node3D с двумя MultiMeshInstance3D (ядро, шуба), живущий в SubViewport сущности. Загрузка бина — несколько PackedByteArray.slice(...).to_float32_array():

var buf := FileAccess.get_file_as_bytes(bin_path)
assert(buf.slice(0, 4).get_string_from_ascii() == "SPL2")
var n_core := buf.decode_s32(4)
var n_shell := buf.decode_s32(8)
var n_buckets := buf.decode_s32(12)
var off := 16
for key in ["core", "shell"]:
    var n: int = n_core if key == "core" else n_shell
    for field in ["pos", "ax0", "ax1", "normal", "color"]:
        parts[key][field] = buf.slice(off, off + n * 12).to_float32_array(); off += n * 12
    parts[key]["opacity"] = buf.slice(off, off + n * 4).to_float32_array(); off += n * 4
    parts[key]["attrs"] = buf.slice(off, off + n * 16).to_float32_array(); off += n * 16
for b in n_buckets:
    orders.append(buf.slice(off, off + n_shell * 4).to_int32_array()); off += n_shell * 4

Шейдер ядра — unshaded, cel-свет считается сам по запечённой нормали: три полосы по N·L ключевого света плюс динамическая вспышка от точечного источника (дуло, взрыв рядом) — ради неё и запекались нормали.

float ndl = clamp(dot(v_normal, -normalize(light_dir)), 0.0, 1.0);
float band = mix(0.62, 0.88, smoothstep(0.22, 0.28, ndl));   // узкие рампы вместо ступеней — анти-поп
band = mix(band, 1.12, smoothstep(0.52, 0.58, ndl));
vec3 col = v_col * base_brightness * band;
col += v_col * v_emis * (0.78 + 0.22 * sin(TIME * 2.3)) * 1.25;          // лампы дышат
vec3 fd = flash_pos - v_world;                                            // вспышка выстрела:
float fndl = clamp(dot(v_normal, normalize(fd)), 0.0, 1.0);               // жёсткий cel-бэнд
float fband = fndl > 0.5 ? 1.0 : (fndl > 0.2 ? 0.45 : 0.0);               // от точечного источника
col += flash_color * flash_strength * fband / (1.0 + 0.15 * dot(fd, fd));

Стенд: исходный GLB и сплат-корпус в игровом масштабе, оборот по yaw. Сплат — это 5 тысяч октагонов с cel-светом по запечённым нормалям.

Четыре ракурса сплат-дрона из игрового вьюпорта.

Теперь грабли. Все три — про Compatibility-рендерер, и все три стоили по дню.

1. custom_data хранится в half-float. Обнаружилось не на сплатах, а на соседней экспериментальной ветке (объёмная решётка, о ней ниже): порт в Godot выдавал «конфетти» при верных данных. Полный CPU-референс той же математики и попиксельный дифф показали, что целые индексы больше 2048 тихо округляются (4482 → 4480), и 57% инстансов читают атрибуты чужих ячеек. С тех пор закон проекта: любой запечённый канал ≤ 255, а part и emis пакуются в одно число как part*16 + emis*15.

2. Гамма. Когда десктопная сборка переехала с Compatibility на Forward+, сплаты стали «почти белыми», а меши с toon-шейдером — нет. Проба из четырёх серых квадов в SubViewport показала: GL Compatibility — чистый gamma-пайплайн (константа 0.5 выходит 0.502), а Forward+ трактует raw-входы как линейные и sRGB-кодирует на выходе (0.5 → 0.737), декодируя только source_color-входы. Сплаты берут цвет из instance COLOR и констант — вот и светлели по кривой. Фикс: все входы raw, математика в гамма-пространстве как тюнилось, финал через свой gamma_out() с глобальным переключателем.

3. .bin — не ресурс Godot. Без явного include-фильтра экспорт его молча не кладёт в билд. На устройстве модели жили на GLB-фолбэке, и сплатным был только аватар (у него был JSON-фолбэк). Поймал за день до того, как фолбэки были удалены — иначе получил бы «пустых мобов» в релизе.

К этому — свод мобильных канонов, которые остались в силе и на десктопе, потому что шейдеры общие: границы циклов только литералами, хэши без sin в долгоиграющих эффектах, никакого pow на пиксель, никакой записи DEPTH из фрагмента.

Реальный кадр из демо и три вырезки из него, 1:1 и ×3: дрон игрока на сплатах, босс на сплатах и рядовой моб на GLB с toon-шейдером. Главный вопрос всей затеи: читается ли облако на сотне пикселей.

Цена в кадре: сплат-босс — 6000 инстансов, один draw call на ядро и один на шубу; на RTX 3060 вся сцена с боссом и толпой держит 4.6–5.4 мс медианы при вьюпортах ×2 и MSAA 8×. Под Deck-пасс ядро получило вариант с круглыми сплатами через alpha_to_coverage — силуэты глаже, артефактов глубины нет, +0.4–0.8 мс на всех сущностях.

Эффекты: сплаты как материал

Вся анимация корпуса — один вершинный шейдер и набор uniform’ов; CPU только крутит ручки. Каждый эффект — отдельная ветка if (uniform > 0), и дефолты нулевые, так что статичный моб их не платит.

Три канала одного шейдера: spawn-сборка (обратный разлёт), заряд ульты (панели приподнимаются по нормалям, свет сочится из щелей по 1−ao), нейро-распад (пакетный глитч целых частей корпуса по part-каналу).

  • scatter / implode / assemble — смерть, коллапс перед детонацией и материализация при спавне. Это один и тот же разлёт по пер-сплатному случайному направлению, проигранный в разные стороны; у scatter четыре режима (радиальный, кольцо в плоскости арены, фейерверк-столб, вихрь) и кинетический bias по вектору убившего выстрела.

  • ripple — узкий фронт от точки попадания бежит по корпусу, сдвигая сплаты по запечённым нормалям.

  • charge — панели левитируют по нормали, сквозь щели светит нутро. Маска — 1 − ao: светятся только «глубокие» сплаты. Этот же канал в красной перекраске стал телеграфом камикадзе: «поднятые панели = сейчас рванёт».

  • link_decay — нейро-деградация. Первая версия с плавной «рябью» синусоидой читалась как желе; работающая — пакетный глитч: время квантуется в слоты, в слоте выбранная хэшем часть корпуса дискретно дёргается и тухнет в циановый, плюс россыпь одиночных «битых пакетов».

  • siphon — ульта-сингулярность: доля сплатов стекает к центру по спирали, вытягиваясь в нить; fract-фаза цикла даёт телепорт на исходник без CPU.

  • part_xform — восемь жёстких «костей» на модель без скелета. Uniform-массивы pivot/кватернион/offset по part-каналу: крылья комаров машут, кольцо конструктора всплывает и крутится, стволы дрона откатываются при выстреле.

if (part_anim > 0) {
    int pp = clamp(int(pid), 0, 7);
    vec4 q = part_quat[pp];
    vec3 dv = VERTEX - part_pivot[pp];
    VERTEX = part_pivot[pp] + dv + 2.0 * cross(q.xyz, cross(q.xyz, dv) + q.w * dv) + part_offset[pp];
    v_normal += 2.0 * cross(q.xyz, cross(q.xyz, v_normal) + q.w * v_normal);
}

Это «скиннинг с одной костью на часть и бинарными весами». Жёсткий поворот обученных сплатов математически точен — картинка не деградирует, в отличие от мягкого скиннинга, который гнул бы ориентации гауссиан. Мягкий скиннинг я отверг как оверинжиниринг, и за три месяца он ни разу не понадобился.

Аватар v2: вспышка выстрела ставится от кончика активного ствола (координаты кончиков — из meta-сайдкара экспорта), накал стволов при перегреве — по радиусу от тех же точек.

Розжиг двигателя: голубая эмиссия на семантической части «engine» + ионный след каплями из пула debris.

Деструкция: кратеры, луковица, граф опоры и куски

Ради этого всё и затевалось. Деструкция живёт в общем модуле (аватар, боссы и соперник используют один код) и работает с ядром как с массивом слотов: каждый слот — инстанс MultiMesh, которому можно поменять трансформ, цвет и канал повреждения.

Кратеры

hull_hit(dmg, dir, kind): вид атаки задаёт геометрию. SHOT — узкая полоса кандидатов на атакованном борту (проекция на нормаль борта); BLAST — широкая; EPICENTER (мина под днищем, навесной снаряд) срывает верх; MELEE — вмятина; PIERCE (рейлган) — спалл на входе, канал трещин по полёту болванки, кусок на выходе. Выбитый сплат либо улетает в пул debris (рождается раскалённо-белым и остывает в полёте по чёрному телу), либо вдавливается внутрь.

Ключевой визуальный фикс первой же версии: выбитый сплат не исчезает, а вдавливается = тёмное дно кратера. Иначе дыры просвечивают фоном, и корпус выглядит не повреждённым, а дырявым.

Кратеры после серии попаданий: тёмное дно, обугленная кромка, холодные искры из пустот.

Луковица

Идея из плейтеста: «выбитый кусок вылетает, а на его месте — такой же, но реже и темнее; с каждым разом дыра глубже и читается внутреннее строение». Реализовано без роста бюджета инстансов: выбитый слот реинкарнируется слоем глубже — позиция = обшивка + inward × шаг × уровень, где inward — минус нормаль с подмесом к центроиду, а шаг пропорционален толщине корпуса в точке. Слот остаётся живым и снова кандидатом хитов, так что «бей в дыру — углубляй» получается бесплатно на той же механике.

Слои — процедурные правила: тёмная подложка → каркас (вытянутые «шпангоуты», у металла — по решётке силового набора с детерминированным шагом, чтобы рёбра соседних кусков стыковались в одну конструкцию) → внутренности → почти чёрное ядро с тлеющими горячими точками и редкими холодными искрами. Учить сплаты глубине нельзя: у генеративных GLB нет ground truth нутра, оно выдумывается в любом случае.

Пять попаданий в один борт: дыра углубляется слой за слоем.

Четыре слоя вглубь, жилы и угли на глубине, и тот же корпус после полного хила. Хил — не «ластик»: каждая дыра помнит свой прогресс заживления, слот лерпится обратно к обшивке.

Граф опоры

После луковицы появились «висячие» сплаты: обшивка, у которой всех соседей углубило на два слоя, формально связана с корпусом через глубину, но визуально парит над ямой. Зонные свипы вокруг последнего удара это не ловили. Решение: граф соседства всей модели строится один раз при загрузке (grid-hash, радиус самокалибруется от N), а дальше каждая смерть или углубление слота событийно (O(соседей)) проверяет окрестность: отрезанный островок до 24 сплатов отрывается куском, неопёртый сплат оседает до уровня соседей, нутро гаснет, когда его родительский регион выбит подчистую.

Из той же серии — пер-частный учёт: у финального босса две пушки — две семантические части; выбил 30% сплатов правой — правый ствол замолчал до конца фазы. Награда за прицельный урон, которая не стоила ни одной новой структуры данных.

Куски вместо конфетти

Версия выше жила два месяца и в целом работала, но на плейтесте перед демо прозвучало «разрушения читаются кашей». Диагноз по кропам: пер-сплатный debris — это конфетти; слои луковицы без структуры; палитра урона красная на пурпурно-красном корпусе; кромка дыры — «выеденность» по отсутствующим эллипсам. Ответ — второй слой запечённых данных.

Материалы. Класс материала сплата по альбедо из тренировки (рецепт на модель: хитин/кость/плоть у органического босса, краска/электроника у механических) со сглаживанием большинством соседей. Шесть классов: default, chitin, bone, flesh, circuit, core.

Куски. k-means++ по позициям внутри каждого материала и семантической части, площадно-взвешенный — куски равной площади, а не равного счёта: на плитах кусок = плита, на зубах и когтях — свои мелкие. Границы рваные: последние итерации Ллойда считают дистанцию с пер-сплатным джиттером, затем проход сглаживания. Размер чанка — ключ читаемости: 25 сплатов ≈ 10 px на экране — не читалось; 100 ≈ 20 px — кусок.

Упаковка — в альфу instance COLOR: 1 + chunk*8 + mat (≤ 2048, f16-точно; старые бины несут 1.0 → кусок 0 / материал 0). Хвост CHNK после SPL2 старые загрузчики не читают — формат остался совместимым.

Теперь попадание трескает чанк (кромка → линия трещины, пара чипов), при достаточном бюджете рвёт его целиком жёстким телом: кусок несёт обшивку и изнанку (копия с нормалью внутрь — кувырок показывает плоть или каркас), рвана только кромка, нутро куска остаётся в масках корпуса. Профили оружия: пушка — трещина плюс каскад (надтреснутый кусок рвётся первым на следующем выстреле), миномёт — радиально от эпицентра, рейлган — спалл на входе и кусок на выходе по полёту.

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

Форма сплата при этом тоже стала данными: класс по перцентилям размера внутри модели (плита ≥ p85 — 5–7-угольник, точка ≤ p40 — круг, между — скруглённый гекс), маска считается во фрагменте через alpha-to-coverage. Целый корпус гладкий; рваность контура включает состояние — кромка раны и дно кратера, — а не шум idle. Это урок более раннего плейтеста: «сплаты гуляют» — любое пер-сплатное мерцание в покое читается как цветовой шум.

Что не взлетело

Самая полезная часть, по-моему.

SDF-суперэллипсоиды

Ветка с собственным растеризатором проиграла гауссианам по четырём прагматичным причинам, ни одна из которых не фундаментальна:

  1. Скорость ×100. Мой растеризатор — глобальная сортировка и front-to-back по всем примитивам на луч, без тайлового биннинга; на 400² упирался в VRAM (семплил 50 тыс. лучей на шаг), 0.7 с/шаг, 30+ минут на ран. gsplat — тайловый CUDA-кернел, 6000 шагов за 21 с. Все дальнейшие аблации были возможны только на 21-секундном цикле.

  2. Качество на бюджет: лосс 0.0137 против 0.008. У гауссиан гладкие, хорошо обусловленные градиенты. У суперэллипсоида α = sigmoid(k(1−F)) с отжигом k от 8 до 60 — либо мыло при малом k, либо мёртвые градиенты при большом (сигмоида насыщается). Ручная реанимация примитивов проиграла MCMC-релокации.

  3. Рантайм-стена: глубина. Гауссиана — квад в MultiMesh. Суперэллипсоид требует либо реймарша во фрагменте (pow на пиксель — красная зона мобильных GPU), либо вывода аналитической изо-глубины из фрагмента для честного z-буфера. Та же стена убила opaque-пасс решётки.

  4. Цветовая детализация. 500–1000 примитивов с константным цветом не тянут декали и панельную роспись; 6 тысяч гауссиан несут деталь в цветах.

Половина находок доехала до прода в запечённом виде: аналитические нормали → нормали с меша; «панель = примитив» → part-ID; точная глубина без сортировки → энтропийная бинаризация. А 32-килобайтный дрон ждёт гибрида «SDF-панели корпуса + гауссова шуба» — после релиза.

Объёмная решётка

Вторая ветка: заменить свободное облако разреженной иерархической решёткой (в духе octree/AMR). Цвет и плотность в вершинах ячеек, позиция неявная (закодирована индексом), порядок отрисовки известен из структуры дерева — никакой сортировки, LOD в подарок. Свой CUDA-растеризатор, тренировка с прунингом (решётка дрона ужалась с 10 тысяч ячеек до 4), порт в Godot.

Плотностная версия работала и была даже стабильнее сплатов во времени (мерцание на дальней дистанции втрое ниже). Версия с SDF в ячейках, которая должна была убить прозрачность как сущность записью глубины из фрагмента, разбилась о рендерер: луч протыкает 15–20 перекрывающихся ячеек, и Compatibility не разрешает эту стопку — мозаика с дырами и муаром. Решётка подарила тот самый баг с half-float и две недели работы. Выигрыш в сортировке я к тому моменту уже получил дешевле — энтропийной бинаризацией.

Рядовые мобы вернулись на меши

Ровно неделю все враги были сплатами. Потом рядовые мобы ушли обратно на GLB с toon-шейдером: на 60–90 px сплат-деструкция не читается и не окупает контент-цену, а главный риск на мобильных GPU — не вершины, а десять полупрозрачных моделей одновременно. Сплаты остались там, где деструкция видна: дрон игрока, пять боссов, соперник. Toon-шейдер мешей повторяет формулу сплатов, чтобы два рендера не спорили в одном кадре.

Два рядовых моба: исходный GLB и его сплат-версия на сером стенде. На игровом масштабе разница не окупает цену.

Кроме того, по дороге отвергнуты: сплат-тень (второй MultiMeshInstance над тем же буфером, проекция на пол вдоль света — работает, но простая 2D-тень читается не хуже), rim-свечение (проявляет структуру примитивов), CoACD-чанки с запечённым vertex color (мажут текстуру), пре-рендер спрайты вместо мобов (квантование на 16–24 направления рядом с живыми сплат-боссами читается инородно).

Бонус: те же сплаты в браузере

Про сайт aster-master.com стоит сказать отдельно, потому что он построен на тех же данных. Задача у него одна — вишлист и кнопка «играть в демо», поэтому первый экран — чистая статика без зависимости от WebGL, а 3D — ленивый чанк поверх. Метафора — спуск в шахту: страница — вертикальный ствол, секции — горизонты с глубиной в метрах (трейлер на 35 м, механика на 80 м, бестиарий на 125 м, логово на дне), камера едет за нативным скроллом с демпфированием, а HUD-глубиномер показывает, где ты. Стек — Vite, ванильный JS и three.js, без фреймворков; весь контент бестиария (имена, статы, описания на двух языках) не хранится на сайте, а собирается из игровых таблиц при сборке, так что правка в игре доезжает на сайт пересборкой.

Там же выяснилось, что сплаты — лучшее представление моделей из имеющихся: игровые GLB несут текстуры 128² и крупным планом не живут, а сплаты учились на детальных рендерах. Дрон на сайте — то самое 16-тысячное облако из первого дня, очищенное от флоатеров и упакованное в SPZ (229 КБ; упаковщик свой, на Python, раскладка сверена побайтно с эталонной). Рендер — Spark поверх three.js, а эффекты — тот же принцип «один GLSL-блок на каждый гауссиан»: сборка из роя искр снизу вверх, багровый свет логова, визор в такт биту. Мобы бестиария — по ~39 КБ каждый, прямо из тренировочных splats.json; их можно крутить мышью на «тактическом мониторе», а смена моба — рассыпание и сборка из искр. Босс на дне сайта (Crimson Skull) — 85 КБ.

Вход в шахту на aster-master.com: дрон — 16 тысяч гауссиан через Spark, кристаллы и плиты — обычные меши three.js.

«Тактический терминал» бестиария: сплат моба приклеен к DOM-витрине и крутится мышью; досье и статы собраны из игровых таблиц при сборке сайта.

Внутри игры у того же подхода есть ещё один потребитель — голограмма-приманка: не снапшот-спрайт, а живая сплат-модель, для которой под бюджет 1800 сплатов отдельно обучено облако (ретрейн с cap_max=1800 вместо среза top-1800 из шеститысячного бина — MCMC распределяет бюджет сам, срез даёт дыры).

Голограмма AFTERIMAGE рядом с дроном: та же модель на 1625 сплатах, умирает разлётом собственных сплатов.

Итоги

Сплатов на модель

6000 (боссы, дрон), 1800 (голограмма), 2800 (пробы мобов)

Непрозрачных после энтропийного штрафа

~98%

Тренировка одной модели

21–22 с (RTX 3060, 6000 шагов, 400²)

Датасет

120–200 видов 1024², Blender headless, ~30 с на модель

Вес в билде

~0.5 МБ на модель против 25–55 МБ GLB

Запечённых каналов

part / emis / ao / edge + материал и чанк

Размер на экране

дрон 72 px, боссы ~112 px

Срок от первого эксперимента до прод-визуала

5 дней; до «кусков» — 2 месяца

Что бы я сделал иначе с самого начала:

  • Судить силуэт только на светлом фоне и только в игровом масштабе. Половина итераций по флоатерам была борьбой с тем, чего на стенде не видно.

  • Проверять, с какого файла рендерится датасет. Урок 128² текстур стоил ретрейна всех боссов, хотя тренер был ни при чём.

  • Сразу считать деструкцию кусками, а не сплатами. Пер-сплатный debris — красивая демка и каша в бою; размер читаемого куска (≈20 px) — это первое, что стоило измерить.

  • Не бояться поднять в игру ограничения, а не возможности. Фиксированная камера дала 64 предрассчитанных сортировки, пояс элевации — belly cull, toon-стиль — отсутствие сферических гармоник. Каждое «мы не умеем» превращалось в «нам и не надо».

Поиграть в результат можно бесплатно: демо AsterMaster в Steam (19–26 октября игра участвует в Steam Next Fest, релиз — 17 ноября), сайт с теми самыми сплатами в браузере — aster-master.com.

Напоследок — честно, почему именно эта затея стала самой кайфовой за год. Не потому, что сплаты «необычно» (хотя да). А потому, что это редкий случай, когда метод из чужой области лёг на игровые ограничения лучше, чем родной: каждое «телефон так не умеет» превращалось в запечённую таблицу, а каждая запечённая таблица — в новый эффект, который не стоил ни одного кадра. Три месяца назад я не знал, что такое gsplat; сейчас у боссов есть внутренности, и их можно вскрыть рейлганом. Значительная часть кода из этой статьи написана в паре с ИИ-агентом (Claude Code) — не секрет и не реклама, просто одной парой рук такой объём экспериментов за лето не вытянуть; решения, вкус и плейтесты — мои, за грабли отвечаем вместе.

Если есть вопросы про gsplat, про Compatibility-рендерер Godot или про то, как заставить облако точек выглядеть мятым металлом — пишите в комментарии, отвечу на всё.

А вот так это выглядит в самой игре: молния босса, дрон на сотне пикселей, и у каждого внутри своя луковица.