Стекло, навесное и анимированные детали¶
Мастерская оружия умеет три вещи, которые не сводятся к перекраске пикселей: сделать выбранную деталь ствола реально прозрачной в игре, вшить в ган новую геометрию (пластину из картинки, объёмный брелок, импортированный .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.
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 не трогаем:
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:
Сам трансформ гоняется по копиям .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], а не отдельной моделью.
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:
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, дописываем свой и пересобираем внешний:
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, из такой картинки контура не получится.
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 дало скролл.
Отдельная мина - размеры блока параметров:
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().
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¶
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 - История с шейдерами.