Инструменты редактора¶
Оружейный редактор («Ганпак») - это страница в WebView поверх TextureStudio. Модель гана приезжает как .glb, собранный из .ydr/.ytd, а каждая текстура становится canvas'ом в браузере: мы рисуем по нему кистью, а на сохранении отдаём PNG обратно в TextureStudio.ReplaceTexture, который мутирует пиксели во всех ресурс-файлах гана.
Главная сложность не в самом рисовании, а в том, что деталь под курсором и тексель, который она читает, - разные вещи. Детали ствола делят один атлас, их UV-острова лежат вплотную, а модеры дополнительно экономят развёртку: несколько деталей могут ссылаться на одни и те же тексели. Отсюда почти вся механика ниже - обрезка мазка по острову, разрывы линии на швах, пересчёт размера кисти из пикселей экрана в тексели.
Ниже - точные диапазоны всех ползунков, пределы кэшей и правила, по которым мазок попадает (или не попадает) в текстуру.
Холст: три канвы на текстуру¶
ensureTexState держит на каждую текстуру три канвы: orig (оригинал, из него черпает ластик), base (пиксельные правки кисти) и comp (base + слои, именно она уходит в GPU-текстуру THREE.CanvasTexture).
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.
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 под курсором в точке нажатия.
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-треугольники меша один раз растеризуются в отдельную канву («этот тексель принадлежит детали»), потом маска режет каждый мазок.
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 гасится, если он длиннее допуска; сама точка ставится всегда - луч в неё честно попал.
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 (тяжёлые редуксы) полный перебор стоил миллисекунды на луч, поэтому там сначала проверяется «свой» меш, а при промахе - меши той же текстуры.
Заливка¶
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'а модели.
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; // обратная сторона гана - не пачкаем
Порядок отсева на треугольник:
- Все три вершины левее / правее / выше / ниже рамки - пропуск.
- Все три вершины дальше
depth / 2вперёд или назад - пропуск. - Нормаль смотрит от камеры (
nrm · fwd >= 0) - пропуск, задние грани не пачкаются. - Площадь треугольника в текселях меньше
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).
Соседние треугольники делят тексели на швах. Складывать их вклады нельзя - шов темнеет от двойной покраски, поэтому побеждает вариант с большей альфой:
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 делается не всегда - слоевые операции пишут только слои.
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 часто байт-в-байт одинаковые, а магазины ссылаются на общий атлас.
// Атомарность: сначала мутируем и сериализуем ВСЕ файлы в память,
// и только если все прошли - пишем на диск.
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-поля остаются прежними, меняются только размерность, формат, уровни и данные.