Перейти к содержанию

Стекло, навесное и анимированные детали

Мастерская оружия умеет три вещи, которые не сводятся к перекраске пикселей: сделать выбранную деталь ствола реально прозрачной в игре, вшить в ган новую геометрию (пластину из картинки, объёмный брелок, импортированный .glb) и собрать отдельный анимированный компонент, который игра сама крутит на стволе.

Все три трогают не текстуру, а drawable: шейдеры, render bucket, вершинные буферы, скелет и bounds. Ошибка тут не выглядит как «криво нарисовалось» - она выглядит как невидимая деталь, белая пластина или вылет на стриминге.

Три сервиса, по одному на задачу:

Сервис Что делает Куда пишет
GlassService шейдер → alpha-bucket, альфа в диффуз ничего на диск, трансформ на установке
AccessoryService + ExtrudeService новая геометрия в .ydr самого гана правит .ydr в рабочей копии
AnimatedDetailService отдельный компонент .ydr+.ycd+.ytyp+.meta подпапка work\_anim\

Стекло: прозрачность даёт не альфа, а render bucket

Первая версия помечала текстуру как стеклянную и просто дописывала ей alpha < 255. В превью на three.js деталь честно просвечивала, а в игре оставалась монолитом. Причина - в bucket'е шейдера:

  • bucket 0 - непрозрачный проход. Движок игнорирует блендинг, и A8R8G8B8 с альфой 100 рисуется так же, как с альфой 255.
  • bucket 1 - alpha-blend проход. Только там sub-255 альфа что-то значит.

Разбор рабочих модерских паков дал эталон: корпус переводится на шейдер-двойник normal_spec_alpha.sps, bucket 1, маска 0xFF02.

MiamiGraphics.Core/Gunsmith/GlassService.cs
public static readonly uint AlphaSpsFile   = JenkHash.GenHash("normal_spec_alpha.sps"); // 2021887493
public static readonly uint AlphaSpsName   = JenkHash.GenHash("normal_spec");
public static readonly uint NormalSpecFile = JenkHash.GenHash("normal_spec.sps");
...
foreach (var sh in shaders)
{
    if (sh == null || sh.RenderBucket != 0) continue;   // уже прозрачный/спец - не трогаем
    string diff = DiffuseName(sh);
    if (diff == null || !glass.Textures.ContainsKey(diff)) continue;

    if ((uint)sh.FileName != NormalSpecFile)
    {
        EnsureFlatHelpers(d, ref flatN, ref flatS);
        RebuildAlphaParams(sh, flatN, flatS);
    }
    sh.FileName = AlphaSpsFile;
    sh.Name = AlphaSpsName;
    sh.RenderBucket = 1;
    sh.RenderBucketMask = (1u << 1) | 0xFF00;   // 0xFF02
}

У normal_spec.sps и normal_spec_alpha.sps param-layout идентичен, поэтому в самом частом случае мы меняем только FileName, Name, bucket и маску, а блок параметров не трогаем вообще. Name при этом остаётся хешем normal_spec - это тоже из эталона.

Для остальных шейдеров блок пересобирается в канонический порядок normal_spec_alpha.sps, а diffuse/bump/spec переносятся из исходного по хешу параметра (DiffuseName ищет параметр, чьё имя содержит diffuse, иначе берёт первую текстуру).

Параметр Значение Зачем
DiffuseSampler перенесён пользовательский скин виден как есть
BumpSampler перенесён или mg_glass_flatn без карты нормалей движок читает диффуз как нормаль
SpecSampler перенесён или mg_glass_flats
HardAlphaBlend (1, 0, 0, 0)
useTessellation (0, 0, 0, 0)
wetnessMultiplier (1, 0, 0, 0)
bumpiness (1, 0, 0, 0)
specMapIntMask (1, 0, 0, 0)
specularIntensityMult (1, 0, 0, 0)
specularFalloffMult (100, 0, 0, 0)
specularFresnel (0.75, 0, 0, 0)

Плоские хелперы - текстуры 4x4 A8R8G8B8, создаются один раз на drawable и кладутся в embedded-словарь: mg_glass_flatn цвета (128, 128, 255), то есть нормаль (0.5, 0.5, 1), и чёрный mg_glass_flats. Недостающие поля (VFT, Unknown_4h, Unknown_30h, Unknown_32h, UsageData) копируются с первой текстуры словаря, а без донора берутся дефолты 2483783232 / 32760 / 1 / 128 / 538269056.

Запекание альфы

Ползунок прозрачности пишется в альфу диффуза равномерно, RGB не трогаем:

MiamiGraphics.Core/Gunsmith/GlassService.cs
var png = TextureCodec.ToPng(tex);
var (bgra, w, h) = TextureCodec.PngToBgra(png);
byte a = (byte)Math.Clamp((int)Math.Round(g.Opacity * 255f), 8, 255);
for (int i = 3; i < bgra.Length; i += 4) bgra[i] = a;

GameTextureWriter.Apply(tex, bgra, w, h);

UI отдаёт непрозрачность в диапазоне 10-92%, код дополнительно зажимает результат в 8-255 - полностью прозрачная деталь неотличима от исчезнувшей.

Запись идёт через GameTextureWriter, а не напрямую. Раньше здесь ванильная сжатая текстура разворачивалась в несжатую A8R8G8B8 с Levels = 1, и ган тяжелел вчетверо на ровном месте: 2048x2048 - это 16 МБ против 4 МБ у BC3. Потолок стриминга - 100 МБ на ресурс, выше него клиент пишет Oversized file (>100MB) и возвращает ноль. GameTextureWriter.Apply наследует формат заменяемой текстуры и не даёт ей вырасти больше оригинала.

Почему стекло не пишется на диск

SetGlass кладёт только пометки в work\_glass.json:

{"textures":{"w_ar_carbine_diff":{"opacity":0.4,"color":"#7fdfff"}}}

Сам трансформ гоняется по копиям .ydr/.ytd в памяти при сборке установочного набора (TextureStudio.BuildInstallFiles). Рабочая копия на диске остаётся чистой и редактируемой, стекло снимается в любой момент, а битый трансформ одного файла ловится catch и не срывает установку - ставится оригинал.

Цвет стекла в UI - только подсказка превью: в игре сквозь деталь виден скин пользователя, RGB мы не подменяем.

Навесное: пластина, брелок и свой .glb

Три входа AccessoryService, все сводятся к одному внутреннему PatchGunFiles:

Метод Геометрия Откуда
AttachQuad 4 вершины, 12 индексов (обе стороны) PNG-наклейка
AttachPendant экструзия контура PNG ExtrudeService
AttachRawMesh произвольный меш импортированный .glb

Клиент присылает точку и нормаль в координатах превью-сцены, мы переводим их в пространство drawable (конвертер .ydr → .glb даёт drawable(x,y,z) → glb(x, z, -y), обратно - (px, -pz, py)) и отодвигаем геометрию от поверхности на 0.004 м, чтобы не получить z-fighting со стволом.

Патчатся все .ydr папки гана, кроме тех, чьё имя содержит _mag или _sight - магазин и прицел это отдельные компоненты, брелок на них не вешаем.

Куда вшивается геометрия

Эталон - вскрытый модерский пак, где брелок сделан ровно так: новая геометрия добавляется в существующую скиновую модель High[0], а не отдельной моделью.

MiamiGraphics.Core/Gunsmith/AccessoryService.cs
var decl = new VertexDeclaration
{
    Types = VertexDeclarationTypes.GTAV1,
    Unknown_6h = 0,
    Flags = skinned ? (uint)0x5F : 89,
    Stride = (ushort)(skinned ? 44 : 36),
    Count = (byte)(skinned ? 6 : 4),
};
Поле вершины Байт Только у скиновой
Position (3 × float) 12 нет
BlendWeights (4 × byte) 4 да
BlendIndices (4 × byte) 4 да
Normal (3 × float) 12 нет
Colour0 (4 × byte) 4 нет
TexCoord0 (2 × float) 8 нет

Итого 44 байта на скиновой декларации 0x5F и 36 на нескиновом фолбэке 0x59. BlendWeights мы пишем как (255, 0, 0, 0) - жёсткая привязка к одной кости, без размазывания.

Кость ищется по приоритету Gun_Main_Bone → Gun_Root → первая корневая (ParentIndex < 0) → 0. Индекс, который уходит в BlendIndices, зависит от того, есть ли у геометрии-донора BoneIds:

MiamiGraphics.Core/Gunsmith/AccessoryService.cs
var ids = donorGeom?.BoneIds;
if (ids != null)
    for (int i = 0; i < ids.Length; i++)
        if (ids[i] == main) return (byte)Math.Min(i, 255);

// BoneIds нет: движок трактует BlendIndices как ПРЯМОЙ индекс кости
return (byte)Math.Min(main, 255);

Шейдер и текстура

Шейдер мы не собираем с нуля, а клонируем рабочий шейдер самого гана и подменяем в клоне первую текстуру на нашу. Так гарантируется совместимость с движком на этой конкретной скиновой модели. Оригинальный ShaderFX не мутируется - новый объект и новый массив параметров, вектор-параметры делят ссылку. Если у гана нет ни одного шейдера с текстурой, собирается default.sps по паттерну FbxConverter.

Текстура-запись повторяет поля модерских файлов (VFT, Unknown_4h = 32760, Unknown_30h = 1, Unknown_32h = 128, UsageData - копия с соседней текстуры файла). Без этого ни OpenIV, ни игра текстуру не находят и рисуют «белую пластину». Параметр DiffuseSampler указывает на сам embedded Texture, а не на отдельный TextureBase.

Раньше мы требовали, чтобы у .ydr был встроенный TextureDictionary, и у паков с диффузом в отдельном .ytd вшивание падало с «не найдено подходящих .ydr». Теперь словарь создаётся на лету.

BoundsData

DrawableModel.BoundsData устроен неочевидно: при одной геометрии это один AABB, при нескольких - первым идёт общий AABB, а дальше per-geom. Добавляя геометрию, мы вытаскиваем прежние per-geom, дописываем свой и пересобираем внешний:

MiamiGraphics.Core/Gunsmith/AccessoryService.cs
per.Add(ComputeAabb(newGeom));
if (per.Count == 1) { model.BoundsData = per.ToArray(); return; }

var outer = new AABB_s { Min = new Vector4(float.MaxValue), Max = new Vector4(float.MinValue) };
foreach (var a in per)
{
    outer.Min = Vector4.Min(outer.Min, a.Min);
    outer.Max = Vector4.Max(outer.Max, a.Max);
}
var res = new List<AABB_s> { outer };
res.AddRange(per);
model.BoundsData = res.ToArray();

Следом расширяются BoundingBoxMin/Max и BoundingSphereRadius самого drawable, иначе деталь отсекается фрустумом раньше ствола.

Лимит вершин

Индексы 16-битные, поэтому импортированная модель обязана уложиться в 65 000 вершин. Проверка стоит трижды: в браузере перед отправкой, в AttachRawMesh и в ExtrudeService (там мы просто возвращаем null, если меш перевалил за 65 000). Ещё две проверки: число индексов должно делиться на 3, а нулевая нормаль заменяется на (0, 0, 1).

Удаление аксессуара (RemoveAccessory) идёт по имени текстуры: выкидываем шейдеры, чья первая текстура совпала, следом геометрии, привязанные к этим шейдерам, ремапим ShaderMapping на новые индексы, чистим словарь и пересобираем bounds. Наши аксессуары узнаются по префиксу имени текстуры brelok.

PNG в объём: как из картинки получается «печенька»

ExtrudeService.Build превращает BGRA-массив в замкнутый меш: лицевая и тыльная грани с PNG-текстурой плюс боковые стенки. Локальные оси: X вправо, Y вниз (0 - верх контура, точка крепления), Z - «перед».

Пять шагов:

1. Бинарная маска с даунскейлом. Длинная сторона режется до 72 пикселей, короткая считается пропорционально, но не меньше 4. Пиксель попадает в маску при alpha >= 64. Если закрашенных пикселей меньше 8 - выходим с null, из такой картинки контура не получится.

MiamiGraphics.Core/Gunsmith/ExtrudeService.cs
const int MaxDim = 72;
int gw, gh;
if (tw >= th) { gw = Math.Min(MaxDim, tw); gh = Math.Max(4, th * gw / tw); }
else          { gh = Math.Min(MaxDim, th); gw = Math.Max(4, tw * gh / th); }

Даунскейл стоит до обхода контура намеренно: число точек периметра задаёт число вершин готового меша, а тот обязан уложиться в 65 000 - проверка на выходе Build возвращает null, если не уложился.

2. Обход контура - Moore-neighbor tracing. Старт - первый закрашенный пиксель при построчном обходе, начальное направление 6 («пришли сверху»), соседи перебираются с backtrack+1. Ограничитель шагов - w * h * 4, чтобы кривая маска не увела в бесконечный цикл.

3. Упрощение - Ramer-Douglas-Peucker с eps = 1.15 пикселя сетки. Контур замкнутый, а RDP работает с разомкнутой ломаной, поэтому мы сначала ищем две самые удалённые точки (перебор с шагом count / 64, чтобы не получить квадратичную сложность на длинном контуре), режем контур по ним на половины и упрощаем каждую отдельно. Дальше знак площади: при отрицательном разворачиваем порядок, приводя полигон к CCW в пиксельных координатах.

4. Триангуляция - ear clipping с предохранителем в 10 000 итераций. При самопересекающемся или вырожденном контуре цикл выходит с тем, что успел построить, вместо того чтобы висеть.

5. Сборка. Ширина модели задаётся в метрах, толщина - долей ширины (зажата в 0.03..0.6). Фронт пишется в обратном порядке индексов: полигон CCW в экранных координатах, где Y вниз, в локальных координатах, где Y вверх, оказывается CW. Боковые стенки получают собственные вершины на каждое ребро - это даёт жёсткую нормаль вместо усреднённой по соседям. Двусторонние треугольники не нужны: объём замкнут фронтом, бэком и стенками.

Анимированная деталь

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

Комплект из четырёх файлов:

Файл Содержимое
<модель>.ydr standalone drawable, скелет из 2 костей, default.sps с globalAnimUV
<модель>_anim.ycd зациклённый клип: UV-скролл либо поворот подвижной кости
<модель>_anim.ytyp CBaseArchetypeDef с clipDictionary и флагами
setcomps_<модель>.meta CWeaponComponentInfo, CreateObject = true

Соглашение имён, подтверждённое хешами: name = assetName = textureDictionary = hash(имя модели), clipDictionary = hash(имя .ycd) = hash(имя .ytyp), хеш анимации = hash(имя модели), хеш клипа = хеш анимации + 1.

Файлы кладутся в подпапку work\_anim\, а не в корень папки гана. Иначе их подобрали бы нерекурсивные сканы мастерской (PickSourceYdr, сборка GLB, ListAccessories) и попытались бы вшить деталь в саму себя.

Скелет и шейдер

Скелет ровно из двух костей: base (tag 0, ParentIndex = -1, флаги 0x1077) и подвижная "1" (tag 417, родитель 0, флаги 0x0077). Tag 417 - это Bone.CalculateBoneHash("1"). Вершины привязываются к кости 0 в режиме UV-скролла и к кости 1 в режимах качания и вращения.

Шейдер - default.sps с параметрами globalAnimUV0/UV1. Метод в коде до сих пор называется BuildEmissiveAnimShader, потому что первым кандидатом был emissive.sps из модерского эталона - он не скроллил UV вообще. Байт-в-байт совпадение с рабочим default.sps дало скролл.

Отдельная мина - размеры блока параметров:

MiamiGraphics.Core/Gunsmith/AnimatedDetailService.cs
shader.ParameterSize = block.ParametersSize;          // 208
shader.ParameterDataSize = block.ParametersDataSize;  // 272 (не BlockLength + 36!)
shader.ParameterCount = (byte)pars.Length;
shader.TextureParametersCount = block.TextureParamsCount;
shader.RenderBucketMask = (1u << shader.RenderBucket) | 0xFF00; // 0xFF01

Соседние сервисы считают ParameterDataSize как BlockLength + 36, и для их шейдеров это верно. Здесь значение надо брать из свойства CodeWalker, иначе игра читает буфер параметров со сдвигом.

UsageData текстуры детали - 538269056. С нулём игра считает текстуру невалидной и рисует деталь белой. У соседних сервисов это поле копируется с донора, но у детали, собранной с нуля, донора нет по природе, поэтому значение прошито константой.

Клип .ycd

Клип генерируется как XML и пропускается через XmlYcd.GetYcd(...).Save().

MiamiGraphics.Core/Gunsmith/AnimatedDetailService.cs
int frames = Math.Clamp((int)Math.Round(req.PeriodSec * 30f) + 1, 8, 600);

Частота 30 кадров в секунду, границы 8 и 600 кадров. SequenceFrameLimit пишется как frames + 30.

Режим Дорожки Данные
uv BoneId 0, Track 17 и 18 линейная рампа offset по U и V
swing BoneId 417, Track 1 кватернион, угол = amplitude * sin(2πt)
spin BoneId 417, Track 1 кватернион, угол = 360° * t

Каналы кодируются двумя способами: если все значения совпали - StaticFloat, иначе QuantizeFloat с Quantum = max((max - min) / 65535, 1e-9) и Offset = min. У качания компонент W кватерниона не пишется - вместо канала стоит CachedQuaternion1 с QuatIndex = 3, и движок восстанавливает W из остальных трёх. У вращения канал W пишется явно.

Архетип .ytyp

MiamiGraphics.Core/Gunsmith/AnimatedDetailService.cs
private const uint ArchetypeFlags = 525824;  // 0x80600
...
var def = new CBaseArchetypeDef
{
    name = modelHash, assetName = modelHash, textureDictionary = modelHash,
    clipDictionary = dictHash,
    assetType = rage__fwArchetypeDef__eAssetType.ASSET_TYPE_DRAWABLE,
    bbMin = bbMin, bbMax = bbMax, bsCentre = bsCentre, bsRadius = bsRadius,
    lodDist = 200f, hdTextureDist = 100f,
    flags = ArchetypeFlags,
};

Бит 0x200 в флагах - «проигрывать анимацию клипа». С 0x80400 деталь спавнилась и стояла неподвижно: файлы на месте, клип на месте, движения нет. Значение 0x80600 сверено с рабочим эталоном.

Размещение и зеркало

Поворот и смещение запекаются прямо в вершины, а не в мету: так деталь ориентируется относительно кости крепления без единого дополнительного узла. Зеркальная копия строится отражением через плоскость X = 0 (X - боковая ось гана) с инверсией X у позиции и нормали и разворотом намотки треугольников; при вершин * 2 > 65 000 зеркало просто не добавляется.

Параметр Диапазон в UI Поле запроса
Размер 2-40 см size (метры)
Толщина 5-45% от ширины depthFrac, зажат в 0.03..0.6
Скорость скролла 0.5-4 scrollU
Период цикла 0.5-8 с periodSec
Поворот X/Y/Z −180…180°, шаг 5 rotX/rotY/rotZ
Смещение X/Y/Z −20…20 см, шаг 0.5 offX/offY/offZ
Кость крепления gun_root + кости ствола attachBone
Зеркало флаг mirror

Установка в игру и список стволов

BuildGameInstall раскладывает набор по трём адресам:

  • .ydr, .ycd, .ytyp - стрим-файлы внутрь miami_guns_selected.rpf. Внутреннее имя .ytyp (CMapTypes.name) обязано совпадать с именем файла.
  • Регистрация %PLATFORM%/levels/gta5/<dict>.ityp как DLC_ITYP_REQUEST с CONTENTS_PROPS в content.xml - сам файл уже стримится, нужна только запись.
  • setcomps_<модель>.meta как WEAPONCOMPONENTSINFO_FILE - loose в корень dlc.rpf.
  • Мета ствола - не в наш DLC, а same-path overlay в update.rpf\dlc_patch\<домашний DLC>\common/data/ai/. Ваниль домашнего DLC грузится позже и перебила бы нашу копию, лежи она в своём паке.

Превью в редакторе собирается для любого ствола, а вот установка требует рецепта: полного шаблона меты этого оружия с нашим компонентом в AttachPoints. Из-за расхождения люди узнавали об ограничении по пустому OpenIV, поэтому IsInstallSupported теперь спрашивается до генерации и предупреждение печатается в лог один раз на ствол.

Ствол Компонент Модель Домашний DLC
Спец. карабин Mk2 SPECIALMK2 speshik mpchristmas2017
Карабин Mk2 CARBMK2ANIM mgd_carbmk2 mpgunrunning
Marksman Mk2 MARKSMK2ANIM mgd_marksmk2 mpchristmas2017
Тяж. снайперка Mk2 HSNIPMK2ANIM mgd_hsnipmk2 mpgunrunning
Пулемёт Mk2 CMGMK2ANIM mgd_cmgmk2 mpgunrunning
ПП Mk2 SMGMK2ANIM mgd_smgmk2 mpgunrunning
Тяжёлая винтовка HEAVYRIFLEANIM mgd_heavyrifle mpsecurity
Прецизионка PRECISIONANIM mgd_precision mpsum2
Револьвер REVOLVERANIM mgd_revolver mpapartment
Мини ПП MINISMGANIM mgd_minismg mpbiker

Рецепт находится по ключу через contains на очищенном имени ствола, поэтому более специфичные ключи стоят в массиве раньше. Ключ specialcarbinemk2 появился вместо прежнего specialcarbine после того, как старый ловил и обычный спец-карабин, и тому молча уезжала мета Mk2 - мета чужого ствола.

Кость нашего слота в шаблонах - плейсхолдер MG_ANIM_BONE, он и заменяется на выбранную пользователем. Легаси-шаблон спец-карабина несёт вместо плейсхолдера gun_root; там замена бланкетная, вхождение единственное.

Три стратегии шаблонов, от самой проверенной:

  • Mk2-стволы - клон рабочего эталона: слот камуфляжа переименован в наш компонент с Default = true. Камо-01 приносится в жертву, остальные камуфляжи целы.
  • Тяжёлая винтовка и револьвер - слот на gun_root уже есть, наш компонент дописан первым, ваниль не тронута.
  • Мини ПП и прецизионка - слота на gun_root не было, добавлен целиком.

Что видно в превью

Превью рабочей копии показывает ган так же, как его увидит игра: с применённым стеклом и с прицепленной аним-деталью. Раньше конвертился голый .ydr с диска, и обе фичи в 3D-просмотре отсутствовали - ган выглядел пустым, хотя в игре был со стеклом и деталью.

Проверка устаревания превью сравнивает время GLB не только с ресурсами гана, но и с _glass.json и содержимым _anim. При этом «редакторское» превью от стекла и детали не зависит: гонять тяжёлую пересборку из-за ползунка прозрачности незачем, игровые слои накладываются только в игровом превью.

Про то, как .ydr вообще доезжает до браузера, - GLB viewer в UI; про перевод RAGE-шейдеров в материалы three.js - История с шейдерами.