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

Инструменты редактора

Оружейный редактор («Ганпак») - это страница в WebView поверх TextureStudio. Модель гана приезжает как .glb, собранный из .ydr/.ytd, а каждая текстура становится canvas'ом в браузере: мы рисуем по нему кистью, а на сохранении отдаём PNG обратно в TextureStudio.ReplaceTexture, который мутирует пиксели во всех ресурс-файлах гана.

Главная сложность не в самом рисовании, а в том, что деталь под курсором и тексель, который она читает, - разные вещи. Детали ствола делят один атлас, их UV-острова лежат вплотную, а модеры дополнительно экономят развёртку: несколько деталей могут ссылаться на одни и те же тексели. Отсюда почти вся механика ниже - обрезка мазка по острову, разрывы линии на швах, пересчёт размера кисти из пикселей экрана в тексели.

Ниже - точные диапазоны всех ползунков, пределы кэшей и правила, по которым мазок попадает (или не попадает) в текстуру.

Холст: три канвы на текстуру

ensureTexState держит на каждую текстуру три канвы: orig (оригинал, из него черпает ластик), base (пиксельные правки кисти) и comp (base + слои, именно она уходит в GPU-текстуру THREE.CanvasTexture).

MiamiGraphics.Shell/gunsmith/web/app.js
let w = img.naturalWidth, h = img.naturalHeight;
const maxSide = Math.max(w, h);
if (maxSide < 256) {
  const k = Math.ceil(512 / maxSide);
  w *= k; h *= k;   // nearest-neighbor: цвета 1-в-1, только пикселей больше
}

Апскейл нужен для утилити-текстур: у части паков диффуз - это 4×4 заливка «black», один тексель которой закрывает половину детали. Рисовать по такой текстуре физически нечем. При сохранении в .ydr уходит увеличенная копия, движок новые размеры принимает.

Композиция base + слои → comp → видеокарта стоит дорого: needsUpdate заливает весь канвас целиком. Поэтому пересборка троттлится по площади текстуры.

Площадь comp Интервал композиции
до 4 Мпикс (2048² и меньше) 33 мс
4-16 Мпикс 66 мс
от 16 Мпикс (4К) 120 мс

На время штриха у GPU-текстуры выключаются мипмапы (generateMipmaps = false, minFilter = LinearFilter) - generateMipmap на 2048²/4К при каждом синке заметно ест кадр на встроенных видеокартах. На pointerup качество возвращается, и финальный flushComposite заливает уже с мипами.

Инструменты и их диапазоны

Размер кисти задаётся в пикселях экрана, а не в текселях (см. следующий раздел). «Сила» - это brush.alpha, множитель прозрачности мазка.

Инструмент Хоткей Параметр Диапазон Дефолт
Осмотр O - - -
Кисть B цвет (HSV-колесо) - #ff2d95
стиль normal / soft / spray / marker normal
размер, px экрана 2-250 40
сила, % 5-100 100
Ластик E размер, px экрана 2-250 40
сила, % 5-100 100
Заливка - цвет - цвет кисти
допуск 1-120 48
Пипетка - - - -
Прозрачность - размер, px экрана 2-250 40
сила, % 5-100 100
Стекло - цвет тонировки - #7fdfff
непрозрачность, % 10-92 40

Ластик не «стирает в ноль», а возвращает оригинал: он рисует паттерном из канвы orig.

MiamiGraphics.Shell/gunsmith/web/app.js
if (mode === 'eraser') {
  st.origPattern ||= st.ctxB.createPattern(st.orig, 'no-repeat');
  ctx.globalAlpha = brush.alpha;
  ctx.strokeStyle = ctx.fillStyle = st.origPattern;
  strokeOrDot(ctx, a, p, size);
}

Стили кисти

Стиль Как кладётся Шаг между отпечатками Множитель силы Композиция
normal линия lineCap/lineJoin = round шириной size - (сплошной отрезок) 1.0 source-over
soft радиальный градиент от цвета к прозрачному size / 5 0.35 source-over
spray max(6, size) точек радиусом 0.8-2.2 текселя, разброс sqrt(random) * size / 2 size / 3 0.5 source-over
marker линия шириной size - 0.6 multiply

Пипетка

Пипетка читает пиксель из comp, а не из base: цвет берётся с учётом слоёв, то есть ровно тот, что виден на модели. Перед чтением вызывается flushComposite(), иначе последние мазки ещё не попали в comp. После взятия цвета редактор автоматически возвращается в предыдущий рисующий инструмент (prevPaintMode).

Прозрачность (альфа)

Мазок кладётся с globalCompositeOperation = 'destination-out' - пробивает дыру в текстуре. Чтобы дыру было видно, материалы текстуры переводятся в transparent = true, side = DoubleSide, depthWrite = false. Пересборка шейдеров делается на входе в инструмент, а не на первом мазке: иначе первый мазок замерзал на пару кадров.

В игре дырка видна только если у шейдера детали ненулевой render bucket. Bucket известен из raycast'а, поэтому на первом мазке в журнал уходит предупреждение: bucket === 0 - деталь непрозрачная, дырки в игре не будет.

Размер кисти: экранные пиксели → тексели

Раньше brush.size был размером в текселях. На утилити-текстуре 4×4, растянутой до 512² апскейлом, кисть «2» закрывала пол-детали, а на 4К-диффузе та же двойка была невидимой точкой. Теперь размер задан в пикселях экрана (как в фотошопе), а в тексели пересчитывается один раз на штрих, по плотности UV под курсором в точке нажатия.

MiamiGraphics.Shell/gunsmith/web/app.js
function texelSizeForHit(hit, st) {
  // мировая длина ребра a-b и его же длина в UV
  const dUV = _u2a.distanceTo(_u2b), dW = _v3a.distanceTo(_v3b);
  if (dUV < 1e-6 || dW < 1e-9) return Math.max(1, brush.size);
  const worldPerUV = dW / dUV;
  _v3r.setFromMatrixColumn(camera.matrixWorld, 0);   // единичный «вправо» камеры
  const s0 = hit.point.clone().project(camera);
  const s1 = hit.point.clone().add(_v3r).project(camera);
  const pxPerWorld = Math.hypot((s1.x - s0.x) * rect.width / 2, (s1.y - s0.y) * rect.height / 2);
  const worldDia = brush.size / pxPerWorld;
  const texDia = (worldDia / worldPerUV) * st.comp.width;
  return Math.min(st.comp.width * 2, Math.max(1, texDia));
}

Потолок - двойная ширина текстуры, пол - 1 тексель. Кольцо-курсор при этом рисуется ровно в brush.size экранных пикселей и не делает ни одного raycast'а на движение мыши.

Обрезка мазка по UV-острову

Кисть кладётся в текстуру кругом радиуса R текселей. Луч честно попадает в свою деталь, но само пятно шире острова: большой кистью краска перелезала границу и красила соседнюю деталь того же атласа. На ванильных стволах Rockstar это особенно заметно - там один меш на весь ган, и мазок по ствольной коробке зарисовывал цевьё.

Лечится маской: UV-треугольники меша один раз растеризуются в отдельную канву («этот тексель принадлежит детали»), потом маска режет каждый мазок.

MiamiGraphics.Shell/gunsmith/web/app.js
const mw = Math.min(2048, W), mh = Math.max(1, Math.round(mw * H / W)), k = mw / W;
ctx.beginPath();
for (let i = 0; i + 2 < n; i += 3) {
  // ... снимаем wrap-смещение, нормализуем обход
  ctx.moveTo(x0, y0);
  ctx.lineTo((bx - du) * mw, (by - dv) * mh);
  ctx.lineTo((cx - du) * mw, (cy - dv) * mh);
  ctx.lineTo(x0, y0);   // ← вместо closePath()
}
ctx.fill('nonzero');

Три неочевидных места:

Замыкание через lineTo, а не closePath(). closePath на таком пути ведёт себя квадратично: 8 тыс. треугольников - 470 мс, 20 тыс. - 2.5 с. Те же пути с ручным замыканием - 6 и 16 мс (сама растеризация в обоих случаях меньше миллисекунды). Подпуть, вернувшийся в первую точку, для заливки уже замкнут.

Одна заливка на весь меш. Разбей её на пачки - и сглаженные края треугольников из разных пачек смешаются дважды, оставив внутри острова полупрозрачные швы (замеряли до 67/255). Кисть по таким швам била бы слабее, чем рядом.

Нормализация обхода. При nonzero зеркальные треугольники, лёгшие друг на друга, гасили бы один другой и пробивали в маске дыру.

Готовая маска раздувается на 2 текселя восемью сдвинутыми копиями снимка (не обводкой пути - там десятки тысяч сегментов с круглыми стыками). Без раздувания мазок обрезался на тексель раньше кромки, и по шву на модели оставалась неокрашенная полоска, которую вдобавок тянет билинейная фильтрация.

Связные острова размечаются по нераздутому снимку (раздутые на pad острова слиплись бы в один): заливка по четырём соседям с порогом альфы > 8, метки в Int16Array.

Пределы маски

Величина Значение Почему
Длинная сторона маски не больше 2048 полноразмерная маска 4К-текстуры весила бы 67 МБ, а точности в 1-2 текселя тут хватает
Раздувание острова pad 2 текселя кромка + билинейная фильтрация
Порог альфы при разметке 8 отсекаем сглаженный край растеризации
Поиск метки на кромке радиус до 4 текселей точка в раздутой зоне не имеет своей метки
Канв отдельных островов на маску 8 вытеснение по первому вставленному
Записей uvMaskCache 4 (UV_MASK_KEEP) канва маски 2048² ≈ 16 МБ; красят всегда одну-две детали подряд
Буфер мазка bbox мазка + size / 2 + 6 на 4К это разница между 67 МБ на каждый мазок и парой сотен килобайт
Ужатие буфера на конце штриха при стороне больше 1024 кисть во всю ширину 4К оставляла бы 4096² ≈ 64 МБ висеть до смены гана

Остров выбирается у каждой точки штриха, а не у старта. Залочить весь штрих на стартовый остров означало мёртвую протяжку: на ванильном стволе десятки панелей-островов, курсор уходит с первого через сантиметр, и маска резала всё в ноль. Смена острова рвёт линию - иначе отрезок тянулся бы через атлас между островами.

Маска бессильна там, где тексели у деталей действительно общие. Это не чинится, поэтому причину делаем видимой: при выборе текстуры все читающие её детали коротко вспыхивают (0.8 с, emissive по синусу).

Разрыв линии на швах

Отрезок lastPx → p гасится, если он длиннее допуска; сама точка ставится всегда - луч в неё честно попал.

MiamiGraphics.Shell/gunsmith/web/app.js
const texPerPx = (strokeTexSize || brush.size) / Math.max(1, brush.size);
strokeStepTexAllow = Math.max((strokeTexSize || brush.size) * 1.5,
                              (dScreen / steps) * texPerPx * 4);
// ...
const allow = strokeStepTexAllow || size * 4;
if (lastPx && Math.hypot(p.x - lastPx.x, p.y - lastPx.y) > allow) lastPx = null;

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

Параметр штриха Значение
Бюджет лучей на кадр 12
Шаг интерполяции по экрану 6 px
Запас на кривизну UV ×4
Пол допуска 1.5 диаметра кисти
Порог «точного» режима raycast 64 меша (STROKE_EXACT_MESH_CAP)

До 64 мешей луч идёт полным intersectObjects и красит только реально видимую поверхность: ближайший хит чужой текстуры - просто разрыв линии, невидимое не красим. Выше 64 (тяжёлые редуксы) полный перебор стоил миллисекунды на луч, поэтому там сначала проверяется «свой» меш, а при промахе - меши той же текстуры.

Заливка

MiamiGraphics.Shell/gunsmith/web/app.js
if (w * h > 4096 * 4096) { log(HG_T('log.fillTooBig'), 'err'); return; }
const match = v => Math.abs((v & 255) - tr) + Math.abs((v >> 8 & 255) - tg)
                 + Math.abs((v >> 16 & 255) - tb) + Math.abs((v >>> 24) - ta) <= tol * 3;

Допуск - сумма модулей разниц по R, G, B и A против допуск × 3. При допуске 48 это до 144 суммарных единиц на четыре канала.

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

На панели развёртки маски нет намеренно - там нет «текущей детали», человек видит весь атлас и заливает то, во что ткнул.

Слои: наклейки, текст, фигуры

Наклейка, текст и фигура - это один и тот же объект-слой в st.layers, отличается только тип и то, чем он рисуется в composite. Слои живут поверх base и не запекаются в пиксели, пока не нажато «Сохранить», - до этого их можно двигать, крутить и править.

Параметр Тип слоя Диапазон Дефолт
Стартовая ширина все - 15 % ширины текстуры
Непрозрачность при создании все - текущая сила кисти
Режим смешивания все - source-over
Текст text - MIAMI
Шрифт text Unbounded, Impact, Arial Black, Manrope, Georgia, Consolas Unbounded
Кегль text 24-800 160
Толщина обводки text 0-60 8
Цвет / цвет обводки text - #ff2d95 / #000000
Тип фигуры shape линия, прямоугольник, круг, треугольник прямоугольник
Толщина линии shape 1-80 16
Скругление (только прямоугольник) shape 0-200 0
Заливка (кроме линии) shape вкл/выкл выкл

Габариты текст-слоя считаются по метрике канваса: ширина measureText(text) + outlineW * 2 (не меньше 8), высота size * 1.3 + outlineW * 2. Масштаб текста меняет кегль, а не растягивает растр, - край остаётся резким. Фигуры тоже рисуются вектором (Canvas2D), поэтому цвет, толщина и скругление правятся уже после размещения.

Встроенных штампов пять (star, heart, smile, MIAMI, paw), каждый рисуется в канву 256×256. Свою картинку можно загрузить как PNG, WebP или JPEG.

Список слоёв рисуется в обратном порядке - верхняя строка соответствует верхнему слою. Стрелки двигают слой по стопке, корзина удаляет, Del удаляет выбранный.

HUD трансформации

Панель внизу сцены появляется только когда выбран слой и активен один из трёх слоевых инструментов (наклейка, текст, фигура). В кисти или осмотре она не висит.

Поле Диапазон Что меняет
Размер, % 10-600 w/h от baseScale/baseH; у текста - кегль от baseSize, не меньше 6
Поворот, ° -180…180 rot в радианах
Прозрачность, % 5-100 opacity слоя

Кнопка «Готово» просто снимает выделение и делает финальный flushComposite; запись в .ydr - это отдельная кнопка «Сохранить».

Проекция фото на модель

Плоский штамп кладёт картинку в 2D-текстуру «как есть». На модели это рвётся: соседние тексели атласа - не соседи на гане. Проекция решает это растеризацией по треугольникам меша: каждый тексель каждого треугольника, попавшего в рамку фото, сэмплирует картинку по своей мировой позиции.

Рамка задаётся точкой тыка (origin), базисом камеры (right, up, fwd) и долей диагонали bbox'а модели.

MiamiGraphics.Shell/gunsmith/web/app.js
const diag = new THREE.Box3().setFromObject(model).getSize(new THREE.Vector3()).length();
const dw = diag * sess.sizeFrac, dh = dw * ih / iw, depth = Math.max(dw, dh);
const FADE = 0.04;   // затухание у рамки фото, доля ширины
// ...
e1.subVectors(vB, vA); e2.subVectors(vC, vA); nrm.crossVectors(e1, e2);
if (nrm.dot(fwd) >= 0) continue;   // обратная сторона гана - не пачкаем

Порядок отсева на треугольник:

  1. Все три вершины левее / правее / выше / ниже рамки - пропуск.
  2. Все три вершины дальше depth / 2 вперёд или назад - пропуск.
  3. Нормаль смотрит от камеры (nrm · fwd >= 0) - пропуск, задние грани не пачкаются.
  4. Площадь треугольника в текселях меньше 1e-6 - пропуск (вырожденный).

Дальше идёт растеризация в пространстве текстуры: bbox треугольника по текселям, барицентрические координаты с допуском -1e-4 на кромке, интерполяция координат в рамке фото и глубины. Тексель отбрасывается, если вылез за рамку [0..1] или за ±depth/2.

Параметр проекции Диапазон Дефолт
Размер (доля диагонали модели), % 4-70 18
Поворот, ° -180…180 0
Затухание края FADE - 0.04 ширины рамки
Порог отброса по альфе - A < 2

Цвет берётся билинейным сэмплом из картинки, альфа умножается на fade = min(1, dx/FADE, (1-dx)/FADE, dy/FADE, (1-dy)/FADE).

Соседние треугольники делят тексели на швах. Складывать их вклады нельзя - шов темнеет от двойной покраски, поэтому побеждает вариант с большей альфой:

MiamiGraphics.Shell/gunsmith/web/app.js
if (A > dst.data.data[off + 3]) {
  dst.data.data[off] = R; dst.data.data[off + 1] = G;
  dst.data.data[off + 2] = Bc; dst.data.data[off + 3] = A;
}

Результат кладётся по одному слою «фото·3D» на каждую задетую текстуру, а bakeSess.layers помнит карту texName → layerId. При сдвиге ползунка размера или поворота проекция пересчитывается целиком и переписывает те же слои; текстуры, из-под которых проекция ушла, чистятся. Перезапекание троттлится: в полёте держится не больше одного прохода, лишние дёрганья ползунка схлопываются в один повтор.

Фигуры в режиме проекции идут тем же путём. Проекция умеет сэмплировать только картинку, поэтому вектор фигуры сначала рисуется в служебный растр 1024 px с полями в 10 % (без отступа обводка по границе рамки побледнела бы от FADE), и этот растр скрыт из сетки наклеек.

Панель UV-развёртки

Правая нижняя панель показывает comp активной текстуры в масштабе 512 px по ширине, поверх шахматки 16 px (#12151d / #1c2130). По ней можно рисовать теми же инструментами - тогда размер кисти пересчитывается в тексели через масштаб панели, а маска островов не применяется.

Галка «UV» включает оверлей развёртки: все UV-треугольники мешов этой текстуры, отрисованные в канву 1024×1024 линией в 1 px цветом rgba(45,212,255,0.35). Оверлей кэшируется на имя текстуры.

Выбранный слой рисуется гизмо: пунктирная рамка (штрих 5/4) и четыре квадратных ручки 8×8 в углах. Тычок по панели либо берёт слой под курсором на перетаскивание, либо, для фигуры в плоском режиме, создаёт новый слой прямо в точке.

История правок

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

В ленте два типа записей: tex (копия канвы base плюс копия массива слоёв) и glass (состояние стекла названных текстур). Снимок base делается не всегда - слоевые операции пишут только слои.

MiamiGraphics.Shell/gunsmith/web/app.js
const HIST_BYTES = 96 * 1024 * 1024;   // потолок на снимки пикселей

function entryBytes(e) { return e.kind === 'tex' && e.base ? e.base.width * e.base.height * 4 : 0; }
function trimHist(stack) {
  let total = stack.reduce((s, e) => s + entryBytes(e), 0);
  while (stack.length > 1 && (total > HIST_BYTES || stack.length > 60)) {
    total -= entryBytes(stack[0]); stack.shift();
  }
}
Предел Значение
Суммарный вес снимков пикселей 96 МБ
Глубина ленты 60 записей
Минимум записей после обрезки 1

Режем с начала ленты: в самое старое состояние просто нельзя будет откатиться, зато всё, что осталось, откатывается корректно. Одна запись 2048² весит 16 МБ, то есть потолок в 96 МБ - это порядка шести снимков крупной текстуры плюс сколько угодно слоевых записей нулевого веса.

applyEntry применяет запись и возвращает обратную, которую кладут в противоположный стек - поэтому «Вернуть» умеет ровно то же, что «Отменить». Если запись относится к неактивной текстуре, редактор сам переключается на неё, чтобы было видно, что откатилось. Новая правка обрывает ветку redo. Пока шаг применяется, стоит флаг histBusy - двойной клик по стрелке не рвёт ленту.

Горячие клавиши

Обработчик игнорирует нажатия, когда фокус в INPUT или SELECT.

Клавиши Действие
Ctrl+Z Отменить
Ctrl+Shift+Z / Ctrl+Y Вернуть
Ctrl+S Сохранить
Del Удалить выбранный слой
O Осмотр
B Кисть
E Ластик
T Текст

Запись обратно в ган

Сохранение отдаёт comp каждой изменённой текстуры как PNG в TextureStudio.ReplaceTexture. Тот подменяет пиксели во всех .ydr/.ytd рабочей копии, где найдено такое имя: у модеров base и _hi часто байт-в-байт одинаковые, а магазины ссылаются на общий атлас.

MiamiGraphics.Core/Gunsmith/TextureStudio.cs
// Атомарность: сначала мутируем и сериализуем ВСЕ файлы в память,
// и только если все прошли - пишем на диск.
var pending = new List<(string Path, byte[] Bytes)>();
foreach (var file in EnumerateResourceFiles(work)) { /* ... */ }

if (pending.Count == 0)
    return new ReplaceResult { Ok = false, Error = /* текстура не найдена */ };

foreach (var (path, bytes) in pending) File.WriteAllBytes(path, bytes);

Без атомарности исключение на _hi оставляло base пропатченным, а _hi - оригиналом, и ган расходился сам с собой. Сам объект Texture при этом не пересоздаётся: Name, NameHash, Usage и Unknown-поля остаются прежними, меняются только размерность, формат, уровни и данные.