Привет, Хабр. Последний год я делаю ритм-рогалик на гексах 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).
splatfacto (nerfstudio) на PBR-рендерах — 41 тысяча гауссиан, фотореал. Красиво, бесполезно: никакой выгоды против меша, а в игре всё равно нужен toon.
splatfacto на cel-рендерах — 16 тысяч. Уже стиль, но бюджет не таргетируется: эвристика adaptive density control просто останавливается, где ей удобно. Для одной модели на экране ок, для босса с эффектами и толпой — нет.
Свой дифференцируемый растеризатор суперэллипсоидов. Идея: примитив с честной резкой гранью
F = (Σ|q|^p)^(1/p), морфящийся от октаэдра до скруглённого бокса, с аналитическими нормалями и «панельной» семантикой. Получился очаровательный дрон из 500 примитивов весом 32 КБ, который сходится — но «кистью», без деталей. Эта ветка и дала имя папке проекта (sdf_splat), и о ней будет отдельный пост-мортем ниже.Сурфели с меша — семплируем поверхность, кладём жёсткие квадраты в касательной плоскости, цвет из текстуры. Никакого обучения. Отвергнуто за час: hard-edge мозаика читается как грязь, сурфели не блендятся.
Победитель — мини-трейнер поверх
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)
Чистка флоатеров: три признака и один урок
Флоатер — сплат, висящий в воздухе рядом с моделью. У целой модели их не видно на чёрном стенде; в игре на сером полу арены они читаются как мусор, а при деструкции их становится больше. Выяснилось, что у флоатера три независимых признака, и ловить нужно все три:
Далеко от меша. Дистанция до поверхности p50/p99 у здорового облака — 0.013/0.057 (в единицах модели 1.6); всё дальше 0.055 — мусор.
Оторван. Граф соседства по grid-hash; живёт только главная компонента. У прод-аватара нашлись кластеры-призраки по 22–24 сплата, висевшие рядом с корпусом.
Торчит из силуэта. Луч игрового ракурса через центр сплата проходит мимо меша. Сетка ракурсов должна быть плотной — 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-суперэллипсоиды
Ветка с собственным растеризатором проиграла гауссианам по четырём прагматичным причинам, ни одна из которых не фундаментальна:
Скорость ×100. Мой растеризатор — глобальная сортировка и front-to-back по всем примитивам на луч, без тайлового биннинга; на 400² упирался в VRAM (семплил 50 тыс. лучей на шаг), 0.7 с/шаг, 30+ минут на ран. gsplat — тайловый CUDA-кернел, 6000 шагов за 21 с. Все дальнейшие аблации были возможны только на 21-секундном цикле.
Качество на бюджет: лосс 0.0137 против 0.008. У гауссиан гладкие, хорошо обусловленные градиенты. У суперэллипсоида
α = sigmoid(k(1−F))с отжигом k от 8 до 60 — либо мыло при малом k, либо мёртвые градиенты при большом (сигмоида насыщается). Ручная реанимация примитивов проиграла MCMC-релокации.Рантайм-стена: глубина. Гауссиана — квад в MultiMesh. Суперэллипсоид требует либо реймарша во фрагменте (
powна пиксель — красная зона мобильных GPU), либо вывода аналитической изо-глубины из фрагмента для честного z-буфера. Та же стена убила opaque-пасс решётки.Цветовая детализация. 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 или про то, как заставить облако точек выглядеть мятым металлом — пишите в комментарии, отвечу на всё.

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