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