3D-ассеты через агента UV-развёртка и текстуры: плотность, атлас, запекание
0%

UV-развёртка и текстуры: плотность, атлас, запекание

UV-развёртка и текстуры: плотность, атлас, запекание

Материал из главы «Материалы, свет и рендер» — это четыре числа: цвет, шероховатость, металличность, прозрачность. Такой материал одинаков во всех точках поверхности. Как только понадобилось, чтобы на боку была надпись, на дне — потёртость, а на ручке — другой оттенок, чисел перестаёт хватать: нужна картинка, натянутая на поверхность.

Чтобы натянуть картинку, поверхность надо разложить на плоскость. Это и есть UV-развёртка: каждой вершине меша сопоставляется точка на квадрате текстуры. Буквы U и V взяты просто потому, что X, Y и Z уже заняты.

Сначала — вопрос, нужны ли они вообще

Робот из главы «Живой кейс» не имеет текстур. Ни одной. Весь его вид — шесть слотов материалов с именами по роли: Robot_Shell, Robot_Visor, Robot_Gold и так далее, каждому назначен Principled BSDF с четырьмя числами.

Это не бедность, а решение, и оно окупилось трижды.

  • Вес. Ассет без текстур весит ровно столько, сколько весит геометрия. Одна текстура 2048×2048 добавила бы к файлу больше, чем весь меш.
  • Перекраска в рантайме. Цвет живёт в слоте, а не в пикселях, поэтому бирюзовая и коралловая команды — это одна модель с разной подстановкой. С текстурой пришлось бы держать два комплекта картинок.
  • Никакой развёртки. Ни швов, ни плотности, ни запекания, ни половины этой главы.

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

Граница проходит примерно так:

Что нужно на поверхности Чем делается Цена
Ровный цвет, металл, пластик, стекло слоты материалов нулевая
Разные материалы на частях объекта несколько слотов вызовы отрисовки
Плавные переходы, грязь, потёртости текстура развёртка плюс вес
Надписи, гербы, номера, лица текстура то же плюс художник
Мелкий рельеф без геометрии карта нормалей то же плюс запекание

Развёртка кодом

Автоматическая развёртка в Blender — оператор, и это сразу означает разговор про контекст:

import bpy, math

bpy.context.view_layer.objects.active = obj
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.uv.smart_project(angle_limit=math.radians(66), island_margin=0.02)
bpy.ops.object.mode_set(mode='OBJECT')

angle_limit задаётся в радианах, хотя в интерфейсе показан в градусах, — классический источник развёртки, разрезанной в неожиданных местах. math.radians здесь не украшение.

island_margin — зазор между кусками развёртки. Ноль выглядит экономно и почти всегда неправилен: при уменьшении текстуры (mip-уровни) соседние пиксели смешиваются, и краска с одного острова заползает на другой. Симптом — тонкая чужая полоска по краю детали, заметная только издалека, то есть ровно тогда, когда её труднее всего связать с причиной.

Ловушка с контекстом. Операторы bpy.ops.uv.* ожидают, что их зовут из области, где развёртка вообще возможна. В интерактивном Blender, поднятом под Xvfb по рецепту из главы «Как устроена связка», такая область есть, и код выше работает как есть. В фоновом blender -b, куда вы позже унесёте пакетную сборку (глава 12), окна нет — и часть операторов там отказывает. Набор отказывающих зависит от версии, поэтому единственный надёжный способ — прогнать свой скрипт в -b до того, как вы повесите на него сборку, а не после.

Швы задаются правилом, а не вкусом

Автоматика режет модель там, где ей удобно. Художник режет там, где шов не видно: в углублениях, на границах материалов, с изнанки. Агент не знает, что такое «не видно», — но он знает геометрию, и правило можно сформулировать в терминах геометрии:

import bmesh

bm = bmesh.new()
bm.from_mesh(obj.data)
for edge in bm.edges:
    if len(edge.link_faces) == 2 and edge.calc_face_angle() > math.radians(50):
        edge.seam = True          # режем по резким рёбрам
bm.to_mesh(obj.data)
bm.free()

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

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

Плотность текселя — число, а не ощущение

Главный критерий качества развёртки — сколько пикселей текстуры приходится на метр поверхности. По-русски это называют плотностью текселя (тексель — пиксель текстуры, «приземлённый» на модель).

Считается это отношением площадей: сколько квадрата развёртки занял объект и сколько у него квадратных метров поверхности. Если развёртка заняла половину квадрата, площадь поверхности — 2 м², а текстура 2048×2048, то на метр приходится 2048 × √0,5 / √2 = 1024 пикселя.

def texel_density(obj, texture_size):
    mesh = obj.data
    mesh.calc_loop_triangles()
    uv = mesh.uv_layers.active.data
    uv_area = world_area = 0.0
    for tri in mesh.loop_triangles:
        a, b, c = (uv[i].uv for i in tri.loops)
        uv_area += abs((b - a).cross(c - a)) / 2
        world_area += tri.area
    return texture_size * (uv_area ** 0.5) / (world_area ** 0.5)

Зачем это нужно, видно на первой же сцене, собранной из разных ассетов. Если у доски 200 пикселей на метр, а у фигуры 800, то в одном кадре стоят мыло и бритва. Глаз замечает не абсолютную резкость, а разницу — и разница читается как «эта модель из другой игры».

Договорённость проекта может выглядеть так:

Что Плотность Обоснование
Дальнее окружение 128 пикс./м в кадре мелкое, крупным планом не бывает
Обычные предметы, декорации 256–512 пикс./м основная масса сцены
То, что игрок видит вблизи 512–1024 пикс./м герой, интерфейсные предметы

Числа в этой таблице — договорённость, а не закон природы. Важно не то, какие они, а то, что они одинаковы у всех ассетов проекта и записаны там, где их видно. Проверка плотности — три строки кода, поэтому ей место в приёмке рядом с бюджетом треугольников из предыдущей главы.

Дешёвая проверка на перекрытие

В чек-листе приёмки из главы про кейс есть пункт «UV не перекрываются непреднамеренно». Точная проверка перекрытия — задача про пересечение многоугольников, дорогая и нудная. Но есть необходимое условие, которое считается мгновенно: сумма площадей островов не может превышать площадь квадрата развёртки.

assert uv_area <= 1.0, f"острова UV занимают {uv_area:.2f} — перекрытие гарантировано"

Если сумма больше единицы — перекрытие есть точно, разбираться не нужно. Если меньше — перекрытие всё ещё возможно, но проверка хотя бы отсеяла грубый случай за одну операцию.

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

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

Атлас: одна текстура на много объектов

Развёртка нескольких объектов в общий квадрат называется атласом. Выгода — не в качестве картинки, а в числе вызовов отрисовки: объекты с общим материалом движок рисует одним пакетом.

bpy.ops.object.select_all(action='DESELECT')
for o in objects:
    o.select_set(True)
bpy.context.view_layer.objects.active = objects[0]

bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.uv.smart_project(angle_limit=math.radians(66), island_margin=0.02)
bpy.ops.object.mode_set(mode='OBJECT')

Цена атласа — связность. Пока всё в одной текстуре, добавить один объект значит пересобрать развёртку всех, а значит переснять все запечённые карты. Для набора, который меняется редко (модульные детали уровня, набор ящиков), это выгодно. Для ассета, который переделывают каждый день, атлас превращается в тормоз.

Запекание: перенести детали с тяжёлой модели на лёгкую

Запекание — это рендер свойств одной поверхности в текстуру другой. Классический случай: есть подробная модель на 400 тысяч треугольников (у нас она уже была — та самая кружка из главы «Экспорт в GLB») и облегчённая на 8 тысяч. Мелкий рельеф с первой переносится во вторую картинкой — картой нормалей, — и лёгкая модель выглядит почти как тяжёлая.

scene = bpy.context.scene
scene.render.engine = 'CYCLES'
scene.cycles.device = 'CPU'
scene.render.bake.use_selected_to_active = True
scene.render.bake.cage_extrusion = 0.02

bpy.ops.object.select_all(action='DESELECT')
high.select_set(True)                       # источник
low.select_set(True)
bpy.context.view_layer.objects.active = low # приёмник
bpy.ops.object.bake(type='NORMAL')

Три вещи, о которых стоит знать заранее.

Запекание идёт только в Cycles. Это ровно тот случай, где вывод главы «Материалы, свет и рендер» работает на нас: на сервере без видеокарты Cycles на процессоре — не компромисс, а лучший доступный вариант.

Оно небыстрое. Карта 2048×2048 с окружающим затенением на восьми ядрах — это минуты, а не секунды, и растёт квадратично от размера. Первый прогон делайте на 512×512: ошибку в настройке клетки (cage_extrusion) видно и на маленькой карте, а ждать в шестнадцать раз меньше.

Приёмник должен быть развёрнут. Запекать некуда, если у лёгкой модели нет UV. Порядок операций жёсткий: сокращение → развёртка → запекание → экспорт. Переставите местами первые два — придётся повторять третий.

Где на самом деле лежит вес

Проверим файл тем же способом, что в главе про экспорт: разбором GLB без сторонних библиотек. Только теперь смотрим не на треугольники, а на картинки.

images = gltf.get("images", [])
views = gltf.get("bufferViews", [])
for img in images:
    if "bufferView" in img:
        size = views[img["bufferView"]]["byteLength"]
        print(f'{img.get("name", "?"):20s} {img.get("mimeType", "?"):12s} {size/1024:8.0f} КБ')

Порядок величин, который стоит держать в голове: одна карта 2048×2048 в PNG — это единицы мегабайт, а полный набор физически корректных материалов (цвет, нормали, шероховатость с металличностью, окружающее затенение) — это четыре такие карты. То есть комплект текстур для одного объекта легко весит больше, чем вся геометрия сцены.

Отсюда рычаги, и они не те же, что для геометрии:

Рычаг Что даёт Цена
Уменьшить карту 2048 → 1024 вчетверо падает плотность текселя
Объединить шероховатость и металличность в каналы одной карты вдвое-втрое путаница, если не записано, что где
Экспорт в JPEG вместо PNG в разы артефакты сжатия, нет прозрачности
Экспорт в WebP в разы, без заметных артефактов нужен рантайм, который его понимает
Сжатие текстур в KTX2 / Basis в разы, и на видеопамяти тоже Blender этого не умеет, нужен отдельный инструмент
Не делать текстуру вовсе всё подходит не всем ассетам

Последняя строка не шутка: именно её выбрал робот из главы про кейс.

Что дальше

У нас есть лёгкая геометрия и текстуры на ней. Модель по-прежнему неподвижна: в разборе GLB из главы про экспорт строки клипы и кости пустые, и в главе про кейс генератор моделей был отвергнут в том числе за то, что его результат нечем анимировать.

Дальше — «Арматура, скиннинг и анимация»: как ассет начинает двигаться, почему у робота из твёрдых деталей скиннинг лучше делать вручную, чем автоматикой, и когда анимацию честнее вообще не класть в файл.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов
Дальше