Гауссовы сплаты как система частиц: разрушаемые корпуса для рогалика на Godot. 3dgs.. 3dgs. blender.. 3dgs. blender. gamedev.. 3dgs. blender. gamedev. gaussian splatting.. 3dgs. blender. gamedev. gaussian splatting. Godot.. 3dgs. blender. gamedev. gaussian splatting. Godot. gsplat.. 3dgs. blender. gamedev. gaussian splatting. Godot. gsplat. PyTorch.. 3dgs. blender. gamedev. gaussian splatting. Godot. gsplat. PyTorch. деструкция.. 3dgs. blender. gamedev. gaussian splatting. Godot. gsplat. PyTorch. деструкция. инди.. 3dgs. blender. gamedev. gaussian splatting. Godot. gsplat. PyTorch. деструкция. инди. шейдеры.

Последний год я делаю ритм-рогалик на гексах 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.

Гауссовы сплаты как система частиц: разрушаемые корпуса для рогалика на Godot - 1

Сплат-корпус дрона после серии попаданий: кратеры, вмятое нутро, горячие осколки. Стендовый кадр, в игре всё это живёт на 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-света и вспышек), принадлежность к части модели, «глубина» внутри корпуса — всего этого у ванильных сплатов нет.

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

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

Гауссовы сплаты как система частиц: разрушаемые корпуса для рогалика на Godot - 2

Три представления одного дрона: 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, лосс вдвое лучше, чем у кастомного растеризатора.

Гауссовы сплаты как система частиц: разрушаемые корпуса для рогалика на Godot - 3

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-синтетики.

Гауссовы сплаты как система частиц: разрушаемые корпуса для рогалика на Godot - 4

Кадры двух датасетов первого дня: 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 (диффуз под белым куполом без солнца).

Гауссовы сплаты как система частиц: разрушаемые корпуса для рогалика на Godot - 5

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

Гауссовы сплаты как система частиц: разрушаемые корпуса для рогалика на Godot - 6

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

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

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

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

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

Гауссовы сплаты как система частиц: разрушаемые корпуса для рогалика на Godot - 7

Один и тот же босс: слева — старый ран (датасет с текстур 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()
Гауссовы сплаты как система частиц: разрушаемые корпуса для рогалика на Godot - 8

Ход тренировки босса: шаг 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 в зависимости от модели. Ретрейн всех пяти боссов батчем — три минуты. Это то, что позволило перепробовать датасеты, бюджеты и штрафы десятками: цикл «поменял параметр — посмотрел» короче, чем кофе.

Гауссовы сплаты как система частиц: разрушаемые корпуса для рогалика на Godot - 9

Два босса из демо по четыре ракурса, отрендеренные прямо из обученных облаков по 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-видов; если диск целиком лежит на прозрачном фоне — это флоатер, который тренер не убил, потому что он суб-пиксельный.

Гауссовы сплаты как система частиц: разрушаемые корпуса для рогалика на Godot - 10

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

Самый действенный фикс оказался не в экспорте, а в датасете: расширить пояс камер вниз с 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));
Гауссовы сплаты как система частиц: разрушаемые корпуса для рогалика на Godot - 11

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

Гауссовы сплаты как система частиц: разрушаемые корпуса для рогалика на Godot - 12

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

Теперь грабли. Все три — про 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 из фрагмента.

Гауссовы сплаты как система частиц: разрушаемые корпуса для рогалика на Godot - 13

Реальный кадр из демо и три вырезки из него, 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), и дефолты нулевые, так что статичный моб их не платит.

Гауссовы сплаты как система частиц: разрушаемые корпуса для рогалика на Godot - 14

Три канала одного шейдера: 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);
}

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

Гауссовы сплаты как система частиц: разрушаемые корпуса для рогалика на Godot - 15

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

Гауссовы сплаты как система частиц: разрушаемые корпуса для рогалика на Godot - 16

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

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

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

Кратеры

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

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

Гауссовы сплаты как система частиц: разрушаемые корпуса для рогалика на Godot - 17

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

Луковица

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

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

Гауссовы сплаты как система частиц: разрушаемые корпуса для рогалика на Godot - 18

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

Гауссовы сплаты как система частиц: разрушаемые корпуса для рогалика на Godot - 19

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

Граф опоры

После луковицы появились «висячие» сплаты: обшивка, у которой всех соседей углубило на два слоя, формально связана с корпусом через глубину, но визуально парит над ямой. Зонные свипы вокруг последнего удара это не ловили. Решение: граф соседства всей модели строится один раз при загрузке (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 старые загрузчики не читают — формат остался совместимым.

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

Гауссовы сплаты как система частиц: разрушаемые корпуса для рогалика на Godot - 20

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

Форма сплата при этом тоже стала данными: класс по перцентилям размера внутри модели (плита ≥ 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-шейдер мешей повторяет формулу сплатов, чтобы два рендера не спорили в одном кадре.

Гауссовы сплаты как система частиц: разрушаемые корпуса для рогалика на Godot - 21

Два рядовых моба: исходный 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 КБ.

Гауссовы сплаты как система частиц: разрушаемые корпуса для рогалика на Godot - 22

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

Гауссовы сплаты как система частиц: разрушаемые корпуса для рогалика на Godot - 23

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

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

Гауссовы сплаты как система частиц: разрушаемые корпуса для рогалика на Godot - 24

Голограмма 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 или про то, как заставить облако точек выглядеть мятым металлом — пишите в комментарии, отвечу на всё.

Гауссовы сплаты как система частиц: разрушаемые корпуса для рогалика на Godot - 25

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

Автор: zakons

Источник