Всем привет! Меня зовут Шико, я работаю в Яндекс Маркете в команде Android-разработки. Сегодня я расскажу историю, которая случилась в 2021 году. Как-то раз перед еженедельным синком я увидел вопрос в рабочем чате: «Кто хочет поучаствовать в проекте связанным с 3D?» А я, пока учился в университете, занимался 3D-моделированием. Тогда для меня это было просто хобби, но я решил вспомнить, каково это, и предложил свою кандидатуру.
Суть задачи была в следующем: нужно было добавить в мобильное приложение AR (то есть, дополненную реальность). Оно нужно, чтобы товар с Маркета можно было «примерить» в интерьер. Например, оно полезно, когда вы хотите купить телевизор, но вам сложно представить, будет ли он гармонировать с мебелью и влезет ли он вообще в имеющееся пространство.
На iOS к проекту подключился один разработчик, а на Android нас было двое. Сначала я расстроился: показалось, что ничего особо интересного не будет — всего-то подключить ARCore и делов. Но это ровно до тех пор, пока не выяснилось, что большинство файлов моделек было в USDZ-формате, а ArCore на тот момент с ним не работал. То есть, когда на iOS в процессе разработки таких проблем не возникало, нам нужно было придумать способ перевести существующие модельки в другой формат — GLB. Казалось бы, скачай конвертер и нажми на кнопку «Конвертировать». Не тут-то было.
И в этой статье я расскажу, какие методы конвертации я пробовал, почему они не подошли и с чем не смогли справиться Blender и Unreal Engine. Спойлер: в итоге мне пришлось написать собственный плагин и я покажу его код.
Пробы и ошибки
Поиск существующего решения
Чтобы решить эту интересную задачку, я попробовал несколько способов решить её малой кровью. Двигался я по такому списку:
онлайн-конвертеры;
Blender;
другие 3D-редакторы;
Unreal Engine.
Онлайн-конвертеры оказались слишком слабыми: они отваливались через 5—10 минут обработки файла. Просто выдавали ошибку.
Для Blender уже был написан плагин, который позволял импортировать USDZ. К сожалению, на большинстве файлов он не работал и падал из-за ошибок в коде. Я пробовал дебажить код и разбираться, в чём же дело. Но было очень много разных ошибок, и я забросил эту идею.
Поиски других 3D-редакторов, которые поддерживали бы формат USDZ, не увенчались успехом. На страницах 3Ds Max и Maya нашёл инфу, что можно подключить плагин, который находится в альфе или бете, что тоже мне не подходило. Другие редакторы уже не припомню. При этом всём я разрабатывал на Linux, соответственно, многие платные редакторы тоже отсеивались.
Unreal Engine
В какой-то момент я решил попробовать Unreal Engine. Оказалось, из коробки он поддерживает импорт USDZ (встроенный плагин в бета-версии), только работает он весьма специфично.
Собственно, первая более-менее успешная попытка конвертировать файл вышла по следующему алгоритму:
Импорт файла USDZ в UnrealEngine.
Экспорт в .obj формат.
Попытка применить текстуры методом тыка в Blender.
Чем этот способ не подходил: Unreal Engine довольно тяжёлый, он требует много ресурсов, и в нём неудобно импортировать/экспортировать и редактировать. При этом на некоторых модельках он неправильно импортировал нормали полигонов или UV-координаты, что приводило к странным артефактам.
В общем, я столкнулся с тем, что в свободном доступе нет ни одного конвертера, который смог бы перенести модельки из USDZ в любой другой формат.
Снова Blender
Параллельно со всем этим я решил всё-таки ещё раз покопать тот плагин к Blender. Я нашёл несколько ошибок в коде. Получилось пофиксить все краши (или почти все), но только я так и не смог пофиксить ошибку при чтении некоторых данных — на многих модельках неправильно считывалась матрица трансформации.
Попробуйте угадать, что это?
Я начал прочёсывать GitHub и вышел на библиотеку Pixar для работы с USDZ. Оказалось, они предоставили все исходники для работы с этим форматом. На всякий попробовал собрать их.
И вот тут мне улыбнулась удача. Оказалось, среди библиотек есть несколько интересных утилит. Среди них меня особенно заинтересовал USDView. Она позволяет просмотреть визуально модельку с дополнительной информацией по ней.
Полученную информацию я решил использовать для дебага плагина к Blender. Спустя пару вечеров, проведённых в USDView, питоновском дебаггере и Blender, я выяснил, что автор библиотеки самостоятельно написал код для парсинга бинарного файла. На каком-то из шагов по непонятной мне ошибке (потратил почти 4 дня на эти трансформации!) этот код иногда пропускает одну из множества матриц трансформаций, что и приводит к покорёженным результатам.
Руками править модельки нереально, учитывая сжатые сроки. Фикс этой проблемы мною был оценен на уровне «вроде изян» по универсальной таблице оценки задачи разработчиками, поэтому решил забросить эту идею.
Я продолжил изучать библиотеку. Тогда у меня возникла первая шальная мысль написать собственный конвертер, изучив кишки библиотеки. Но эта задача тоже выглядела «вроде изян». И только я собрался отказаться от идеи, как я нахожу в этой библиотеке софтину Usdcat.
Как оказалось, Pixar сделали три возможных формата файла:
USDC — бинарный файл, который хранит в себе всю инфу по сетке и трансформациям, а также информацию о материалах без текстур.
USDA — то же, что и USDC, только в текстовом формате.
USD — он может быть как бинарным так и текстовым.
USDZ — по сути, это zip-архив, внутри которого есть USDC или USDA файл и текстуры.
Так вот, Usdcat позволяет конвертировать бинарный файл модели в его текстовое представление, что очень удобно для чтения данных и отладки процесса конвертации.
На этом этапе я собрал Blender из исходников как внешнюю библиотеку для Python, и в качестве эксперимента начал писать прототип конвертера.
Как я писал свой конвертер
Внимание: дальше идёт не очень хороший код от новичка на Python. Уберите впечатлительных разработчиков от экрана.
Процесс конвертации я разбил на несколько этапов:
Распаковка USDZ-файла.
Конвертация USDC в USDA.
Парсинг USDA, преобразование его в более удобочитаемую структуру в памяти.
Обработка структуры, преобразование её в набор команд для Blender для создания сцены с моделькой.
Сохранение в *.blend файл для последующей отладки.
Конвертация сцены в GLB.
Думаю, из всего перечисленного самое интересное — парсинг и команды для Blender.
Код
# Точка входа в конвертер
def try_to_convert(input_file, blend_file, output_file, debug_file=None):
# Ищем утилиту usdcat в окружении
usd_cat = find_exe('usdcat')
# Создаём пустую сцену в blender
create_empty_scene()
# Запускаем usdcat, скармливаем ему модельку, считываем результат
file_data = read_file(usd_cat, input_file)
# Придётся распаковать файл, чтобы была возможность импортировать текстуры в Blender
extracted_path = extract_file(input_file)
# На всякий случай сохраняем полученные данные, чтобы была возможность просто и быстро дебажить, если что-то пойдёт не так
save_data(file_data, debug_file)
# Парсим полученное текстовое представление файла
parsed_data = parse_data(file_data) # Нам интересно вот это
# По распаршенным данным создаём модельку
import_scene_data(parsed_data, extracted_path) # и это
# Сохраняем полученную сцену для дальнейшего анализа ошибок
save_scene(blend_file)
# Экспортируем в glb
export_scene(output_file)
# Очищаем ресурсы
clean(extracted_path)
Начнём с парсинга. В текстовом представлении USDC/USDZ/USDA выглядит следующим образом:
Код
#usda 1.0
(
customLayerData = {
string creator = "usdzconvert preview 0.62"
}
defaultPrim = "modul_01"
metersPerUnit = 1
upAxis = "Y"
)
def Xform "modul_01" (
assetInfo = {
string name = "modul_01"
}
kind = "component"
)
def Scope "Materials"
{
def Material "DVP_komod_2_Letta_Malta_modul_01"
{
token outputs:surface.connect = </modul_01/Materials/DVP_komod_2_Letta_Malta_modul_01/surfaceShader.outputs:surface>
def Shader "surfaceShader"
{
uniform token info:id = "UsdPreviewSurface"
color3f inputs:diffuseColor.connect = </modul_01/Materials/DVP_komod_2_Letta_Malta_modul_01/diffuseColor_texture.outputs:rgb>
float inputs:roughness = 1
token outputs:surface
}
...
}
...
def Scope "Geom"
{
def Mesh "DVP_komod_2_Letta_Malta_modul_01"
{
uniform bool doubleSided = 1
int[] faceVertexCounts = [3, 3,...]
rel material:binding = </modul_01/Materials/DVP_komod_2_Letta_Malta_modul_01>
point3f[] points = [(-0.34295827, 0.2727909, -0.1854379), (0.3439359, 0.27179047, -0.1864381), ...]
normal3f[] primvars:normals = [(2.7196302e-7, 2.3841858e-7, 1), (1, -0.0000013478456, -1.2789769e-13), ...]
texCoord2f[] primvars:st = [(0.6426813, 0.66292274), (0.8511379, 0.32041615), ...]
uniform token subdivisionScheme = "none"
quatf xformOp:orient = (1, 0, 0, 0)
float3 xformOp:scale = (1, 1, 1)
double3 xformOp:translate = (0, 0, 0)
uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:orient", "xformOp:scale"]
}
...
}
На что я обратил внимание. Практически во всех файлах всегда были:
Заголовок с описанием сцены (масштабы, верхняя ось и другие данные).
Xform — структура, которая может содержать другие xform, инфу о геометрии и материалах, а также информацию о матрице трансформации.
Scope — по сути, это разные xform, объединённые по разному признаку (например, геометрия или материалы).
Mesh — информация о геометрии модели (все вершины, грани, полигоны и прочее), также может содержать информацию о трансформации.
Material — информация о материале, набор шейдеров разного типа.
Парсер из себя представляет обычный конечный автомат с большим количеством переходов. Так как задача подразумевала разово преобразовать пару десятков или сотен моделей, в красоту и удобство кода я не вкладывался совсем. Алгоритм простой: проходим по всем строкам, на начало каждой. Если совпадает с константой — переходим в нужное состояние или считываем нужный тип данных, если нет — крашим парсинг с указанием неизвестной структуры.
Список констант, который пришлось обработать:
Код
class ParseConstants:
usda_desc = '#usda'
up_axis = 'upAxis'
meters_per_unit = 'metersPerUnit'
scope = 'def Scope '
mesh = 'def Mesh '
xform = 'def Xform '
subset = 'def GeomSubset '
material = 'def Material '
shader = 'def Shader'
# operations
matrix4d = 'matrix4d'
op_orient = 'quatf xformOp:orient'
op_scale = 'float3 xformOp:scale'
op_translate = 'double3 xformOp:translate'
op_transform = 'matrix4d xformOp:transform'
op_order = 'uniform token[] xformOpOrder'
ops_map = {
'xformOp:translate': Operation.TRANSLATE,
'xformOp:orient': Operation.ORIENT,
'xformOp:scale': Operation.SCALE,
'xformOp:transform': Operation.TRANSFORM
}
double_sided = 'uniform bool doubleSided'
face_vertex_count = 'int[] faceVertexCounts'
face_vertex_index = 'int[] faceVertexIndices'
material_binding = 'rel material:binding'
points = 'point3f[] points'
interpolation = 'interpolation'
normals_indices = 'int[] primvars:normals:indices'
normals = 'normal3f[] normals'
normals_primvars = 'normal3f[] primvars:normals'
int_primvars = 'int[] primvars:'
subdivision_scheme = 'uniform token subdivisionScheme'
extent = 'float3[] extent'
text_coord = 'texCoord2f[] '
element_type = 'uniform token elementType = "face"'
family_name = 'uniform token familyName = "materialBind"'
indices = 'int[] indices'
# materials
token = 'token'
metallic = 'metallic'
roughness = 'roughness'
emissive_color = 'emissiveColor'
normal = 'normal'
occlusion = 'occlusion'
diffuse_color = 'diffuseColor'
opacity = 'opacity'
specular_color = 'specularColor'
use_specular_workflow = 'useSpecularWorkflow'
varname = 'varname'
file = 'file'
st = 'st'
default = 'default'
bias = 'bias'
scale = 'scale'
ior = 'ior'
displacement = 'displacement'
clearcoat = 'clearcoat'
clearcoat_roughness = 'clearcoatRoughness'
opacity_threshold = 'opacityThreshold'
wrap_s = 'wrapS'
wrap_t = 'wrapT'
st_primvar_name = 'stPrimvarName'
surface = 'surface'
result = 'result'
rgb = 'rgb'
r = 'r'
b = 'b'
g = 'g'
a = 'a'
connect = '.connect'
type_to_mat_type = {
'uniform token': InfoType.UNIFORM_TOKEN,
'float': InfoType.FLOAT,
'color3f': InfoType.COLOR3F,
'normal3f': InfoType.NORMAL3F,
'int': InfoType.INT,
'float2': InfoType.FLOAT2,
'float3': InfoType.FLOAT3,
'float4': InfoType.FLOAT4,
'token': InfoType.TOKEN,
'asset': InfoType.ASSET,
}
desc_to_direction = {
'inputs': InfoDirection.INPUT,
'outputs': InfoDirection.OUTPUT,
}
token_id_to_shader_type = {
'UsdPreviewSurface': ShaderTokenType.PREVIEW_SURFACE,
'UsdPrimvarReader_float2': ShaderTokenType.UV_COORDINATES,
'UsdUVTexture': ShaderTokenType.UV_TEXTURE,
}
Пример кода чтения материала (примерно то же самое для других структур, только больше строк):
Код
def read_material(data, counter):
current_line = data[counter.line]
name = read_name(current_line)
shaders = []
outputs = []
while True:
counter.inc()
current_line = data[counter.line]
if current_line.startswith(ParseConstants.token):
outputs.append(read_mat_info(current_line))
elif current_line.startswith(ParseConstants.shader):
shaders.append(read_shader(data, counter))
elif current_line == '{':
continue
elif current_line == '}':
break
else:
raise_parse_error(current_line)
return Material(
name=name,
shaders=shaders,
outputs=outputs,
)
После парсинга получаем структуру, в которой есть заголовок, набор материалов и набор геометрии. Преобразуем их в 3D-сцену.
Основные операции в Blender выполняются через объект bpy. Позабавило, что некоторые операции выглядят как описание действий непосредственно в редакторе. Например, вот удаление объекта:
def remove_mat_dummy():
bpy.ops.object.select_all(action='DESELECT') # Снимаем выделение со всех объектов, чтобы ненароком не удалить ничего лишнего
bpy.data.objects['materials_dummy'].select_set(True) # Выделяем нужный нам объект
bpy.ops.object.delete() # Вызываем операцию удаления
Рекурсивно проходимся по всем xform и отрисовываем их контент. При этом не забываем посчитать матрицу трансформации — это произведение матрицы дочернего объекта на матрицу родительского.
Код
def import_xform(xform, materials, parent_matrix, parent=None):
matrix = xform.matrix4d * parent_matrix
if len(xform.meshes) == 0:
if len(xform.children) == 0:
return
obj = create_empty_object(xform.name)
add_to_default_collection(obj)
if parent is not None:
obj.parent = parent
import_xforms(xform.children, materials, matrix, obj)
else:
# todo read uvs
for mesh in xform.meshes:
obj = create_mesh_object(mesh.name)
add_to_default_collection(obj)
add_mesh_data(obj, mesh, materials, matrix)
if parent is not None:
obj.parent = parent
import_xforms(xform.children, materials, matrix, obj)
Самые сложные части в конвертации: импорт геометрии и импорт материалов.
Импорт геометрии
Работа с геометрией была во многом подсмотрена в плагине к Blender, на который я ссылался выше.
Код
def add_mesh_data(obj, data, materials, parent_matrix):
# Для каждой текстуры создаём слот для uv-координат
for name, coordinates in data.text_coordinates.items():
obj.data.uv_layers.new(name=name)
# Считаем матрицу трансформации
matrix = data.transform * parent_matrix
counts = data.face_vertex_count
indices = data.face_vertex_indices
verts = data.points
faces = []
smooth = []
index = 0
for count in counts:
# Записываем полигоны. По сути полигоны состоят из двух массивов: массив координат точек и массив, описывающий соединения этих точек. Здесь описываем соединения.
faces.append(tuple([indices[index + i] for i in range(count)]))
if len(normals) > 0:
smooth.append(len(set(normals[index + i] for i in range(count))) > 1)
else:
smooth.append(True)
index += count
bm = bmesh.new()
bm.from_mesh(obj.data)
# Сохраняем вершины
v_base = len(bm.verts)
for vert in verts:
bm.verts.new(vert)
bm.verts.ensure_lookup_table()
# Применяем материалы
main_mat_index = 0
if data.material is not None:
main_mat_index = add_material_to_obj(obj, data.material, materials)
mat_indices = [main_mat_index for _ in range(len(faces))]
# Некоторые полигоны могут отличаться от основного материала, здесь это учитываем
for s in data.subsets:
if s.indices is not None and s.material is not None:
index = add_material_to_obj(obj, s.material, materials)
for i in s.indices:
mat_indices[i] = index
# Add the Faces
for i, face in enumerate(faces):
if len(face) == len(set(face)):
f = bm.faces.new((bm.verts[i + v_base] for i in face))
f.material_index = mat_indices[i]
f.smooth = smooth[i]
# Сохраняем uv-координаты
for name, coordinates in data.text_coordinates.items():
uv_indices = data.indices[name] if name in data.indices else data.face_vertex_indices
mapped_uv = [coordinates[i] for i in uv_indices]
obj.data.uv_layers.new(name=name)
uv_index = bm.loops.layers.uv[name]
index = 0
for f in bm.faces[-len(faces):]:
for i, l in enumerate(f.loops):
if index + i < len(mapped_uv):
l[uv_index].uv = mapped_uv[index + i]
else:
l[uv_index].uv = (0.0, 0.0)
index += len(f.loops)
bm.to_mesh(obj.data)
bm.free()
# Применяем матрицу трансформации
obj.data.transform(matrix=tuple(x for x in matrix.tolist()))
obj.data.update()
mat_indices.clear()
Импорт материалов
Здесь я использовал материал BSDF_PRINCIPLED, для которого мог задать в качестве ввода следующие параметры:
Base Color,
Specular,
Metallic,
Roughness,
Clearcoat,
Clearcoat Roughness,
Emissive,
IOR,
Opacity,
Normal.
Как это могло выглядеть в сцене (скрин с новой версии blender, но суть та же):
Отрывки из кода
def create_material(material, ext_dir):
mat = bpy.data.materials.new(material.name) # Создаём материал
mat.use_nodes = True # Используем систему нодов
for shader in material.shaders:
import_shader(mat, material.shaders, shader, ext_dir) # Импортируем каждый шейдер
return mat
def import_shader(blend_material, shaders, shader, ext_dir):
bsdf_node = get_node_by_type(blend_material, 'BSDF_PRINCIPLED')
# Шейдеры бывают трёх типов:
# PREVIEW_SURFACE — число или вектор;
# UV_TEXTURE — текстура или картинка;
# UV_COORDINATES — uv-координаты.
if shader.token_id == ShaderTokenType.PREVIEW_SURFACE:
import_base_preview_surface(blend_material, shaders, shader, bsdf_node, ext_dir)
elif shader.token_id == ShaderTokenType.UV_TEXTURE:
import_base_uv_texture(blend_material, shader, shaders, ext_dir)
elif shader.token_id == ShaderTokenType.UV_COORDINATES:
import_uv_coordinates(blend_material, shader)
def import_base_preview_surface(blend_material, shaders, shader, bsdf_node, ext_dir):
bsdf_node.name = shader.name
set_node_input(
blend_material=blend_material,
shaders=shaders,
node=bsdf_node,
input_desc=shader.diffuse_color,
desc='Color',
node_input_name='Base Color',
input_type=InfoType.COLOR3F,
ext_dir=ext_dir,
)
set_node_input(
blend_material=blend_material,
shaders=shaders,
node=bsdf_node,
input_desc=shader.specular,
desc='Specular',
node_input_name='Specular',
input_type=InfoType.COLOR3F,
ext_dir=ext_dir,
)
# Далее проброс данных для других примитивных данных
#...
def set_node_input(blend_material, shaders, node, input_desc, desc, node_input_name, input_type, ext_dir):
if input_desc is not None:
if input_desc.info_type != input_type:
raise_import_error('%s type is %s instead of %s' % (desc, input_desc.info_type, input_type))
# Если в описании есть connection — это, вероятно, текстура (в чём была разница между текстурой и UV-текстурой? уже не помню). Соответственно, нужно создать ноду текстуры и подключить вывод ноды текстуры с правильным вводом текущей ноды.
if input_desc.is_connection:
set_shader_input_texture(
blend_material=blend_material,
shaders=shaders,
node=node,
input_name=node_input_name,
input_desc=input_desc,
ext_dir=ext_dir,
)
else:
# Для цвета, числа или вектора всё проще: можно задать значение в самом вводе ноды, не заморачиваясь с созданием дополнительной
if input_desc.info_type == InfoType.COLOR3F:
set_shader_input_value(node, node_input_name, ast.literal_eval(input_desc.value) + (1,))
elif input_desc.info_type in (InfoType.FLOAT, InfoType.NORMAL3F):
set_shader_input_value(node, node_input_name, ast.literal_eval(input_desc.value))
else:
# При получении неизвестного типа данных падаем и разбираемся, какой ещё тип данных мы пропустили
raise_import_error('Unsupported data type')
Что в итоге
Конвертер не был завершён. Я остановился на отметке 80-90% сконвертированных файлов — этого вполне хватило для фичи.
Но на старте мы решили запустить ещё одну идею. Быстро сгенерировать руками модели тысяч телевизоров, стиралок, холодильников, мебели и прочих предметов невозможно, а фичу хочется максимально заиспользовать. Было принято решение нагенерировать «коробок» с определёнными размерами и показывать их в качестве заглушки, чтобы пользователь мог «примерить» товар у себя дома хотя бы по габаритам.
Я сделал это с помощью USDA-файла. А именно создал файл-шаблон, в котором уже описана вся геометрия и материалы, только вместо позиций вершин стоят заглушки.
Код
#usda 1.0
(
customLayerData = {
string creator = "Yandex.Market. All rights reserved 2021"
}
defaultPrim = "Box"
upAxis = "Y"
metersPerUnit = 1
)
def Xform "Box" (
assetInfo = {
string name = "Box"
}
kind = "component"
)
{
def Scope "Geom" {
def Xform "Root"
{
def Mesh "Cube"
{
uniform bool doubleSided = 1
int[] faceVertexCounts = [4, 4, 4, 4, 4, 4]
int[] faceVertexIndices = [0, 1, 3, 2, 2, 3, 7, 6, 6, 7, 5, 4, 4, 5, 1, 0, 2, 6, 4, 0, 7, 3, 1, 5]
point3f[] points = [(-width, 0, 0), (-width, 0, length), (-width, height, 0), (-width, height, length), (width, 0, 0), (width, 0, length), (width, height, 0), (width, height, length)]
normal3f[] primvars:normals = [(-1, 0, 0), (0, 1, 0), (1, 0, 0), (0, 0, 0), (0, 0, -1), (0, 0, 1)] (
interpolation = "faceVarying"
)
int[] primvars:normals:indices = [0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5]
uniform token subdivisionScheme = "none"
rel material:binding = </Box/Materials/BoxMaterial>
}
def Mesh "Plane1"
{
uniform bool doubleSided = 0
float3[] extent = [(-0.5, 0, 0), (0.5, 1, 0.0001)]
int[] faceVertexCounts = [3, 3]
int[] faceVertexIndices = [0, 1, 3, 0, 3, 2]
rel material:binding = </Box/Materials/LogoMaterial>
normal3f[] normals = [(0, 0, 1), (0, 0, 1), (0, 0, 1), (0, 0, 1)]
point3f[] points = [(-plane1Left, plane1Bot, plane1Depth), (plane1Right, plane1Bot, plane1Depth), (-plane1Left, plane1Top, plane1Depth), (plane1Right, plane1Top, plane1Depth)]
texCoord2f[] primvars:st = [(0, 0), (1, 0), (0, 1), (1, 1)] (
interpolation = "vertex"
)
uniform token subdivisionScheme = "none"
}
def Mesh "Plane2"
{
uniform bool doubleSided = 0
float3[] extent = [(-0.5, 0, 0), (0.5, 1, 0.0001)]
int[] faceVertexCounts = [3, 3]
int[] faceVertexIndices = [0, 1, 3, 0, 3, 2]
rel material:binding = </Box/Materials/MLetterMaterial>
normal3f[] normals = [(0, 0, 1), (0, 0, 1), (0, 0, 1), (0, 0, 1)]
point3f[] points = [(-plane2Left, plane2Bot, plane2Depth), (plane2Right, plane2Bot, plane2Depth), (-plane2Left, plane2Top, plane2Depth), (plane2Right, plane2Top, plane2Depth)]
texCoord2f[] primvars:st = [(0, 0), (1, 0), (0, 1), (1, 1)] (
interpolation = "vertex"
)
uniform token subdivisionScheme = "none"
}
def Mesh "Plane3"
{
uniform bool doubleSided = 0
float3[] extent = [(-0.5, 0, 0), (0.5, 1, 0)]
int[] faceVertexCounts = [3, 3]
int[] faceVertexIndices = [0, 1, 3, 0, 3, 2]
rel material:binding = </Box/Materials/MTailMaterial>
normal3f[] normals = [(0, 0, 1), (0, 0, 1), (0, 0, 1), (0, 0, 1)]
point3f[] points = [(plane3Width, plane3Bot, plane3DepthClose), (plane3Width, plane3Bot, plane3DepthFar), (plane3Width, plane3Top, plane3DepthClose), (plane3Width, plane3Top, plane3DepthFar)]
texCoord2f[] primvars:st = [(0, 0), (tex3, 0), (0, 1), (tex3, 1)] (
interpolation = "vertex"
)
uniform token subdivisionScheme = "none"
}
def Mesh "Plane4"
{
uniform bool doubleSided = 0
float3[] extent = [(-0.5, 0, 0), (0.5, 1, 0)]
int[] faceVertexCounts = [3, 3]
int[] faceVertexIndices = [0, 1, 3, 0, 3, 2]
rel material:binding = </Box/Materials/PromoMaterial>
normal3f[] normals = [(0, 0, 1), (0, 0, 1), (0, 0, 1), (0, 0, 1)]
point3f[] points = [(-plane4Left, plane4Bot, plane4Depth), (plane4Right, plane4Bot, plane4Depth), (-plane4Left, plane4Top, plane4Depth), (plane4Right, plane4Top, plane4Depth)]
texCoord2f[] primvars:st = [(0, 0), (1, 0), (0, 1), (1, 1)] (
interpolation = "vertex"
)
uniform token subdivisionScheme = "none"
}
}
}
def Scope "Materials"
{
def Material "MTailMaterial"
{
token outputs:surface.connect = </Box/Materials/MTailMaterial/surfaceShader.outputs:surface>
def Shader "surfaceShader"
{
uniform token info:id = "UsdPreviewSurface"
color3f inputs:diffuseColor.connect = </Box/Materials/MTailMaterial/diffuseColor_opacity_texture.outputs:rgb>
color3f inputs:emissiveColor = (0, 0, 0)
float inputs:metallic = 0
normal3f inputs:normal = (0, 0, 1)
float inputs:occlusion = 1
float inputs:opacity.connect = </Box/Materials/MTailMaterial/diffuseColor_opacity_texture.outputs:a>
float inputs:roughness = 0.5
token outputs:surface
float inputs:opacityThreshold = 0.5
}
def Shader "st_texCoordReader"
{
uniform token info:id = "UsdPrimvarReader_float2"
token inputs:varname = "st"
float2 outputs:result
}
def Shader "diffuseColor_opacity_texture"
{
uniform token info:id = "UsdUVTexture"
asset inputs:file = @textures/m_tail.png@
float2 inputs:st.connect = </Box/Materials/MTailMaterial/st_texCoordReader.outputs:result>
token inputs:wrapS = "clamp"
token inputs:wrapT = "clamp"
float3 outputs:rgb
float outputs:a
}
}
def Material "MLetterMaterial"
{
token outputs:surface.connect = </Box/Materials/MLetterMaterial/surfaceShader.outputs:surface>
def Shader "surfaceShader"
{
uniform token info:id = "UsdPreviewSurface"
color3f inputs:diffuseColor.connect = </Box/Materials/MLetterMaterial/diffuseColor_opacity_texture.outputs:rgb>
color3f inputs:emissiveColor = (0, 0, 0)
float inputs:metallic = 0
normal3f inputs:normal = (0, 0, 1)
float inputs:occlusion = 1
float inputs:opacity.connect = </Box/Materials/MLetterMaterial/diffuseColor_opacity_texture.outputs:a>
float inputs:roughness = 1.0
token outputs:surface
float inputs:opacityThreshold = 0.5
}
def Shader "st_texCoordReader"
{
uniform token info:id = "UsdPrimvarReader_float2"
token inputs:varname = "st"
float2 outputs:result
}
def Shader "diffuseColor_opacity_texture"
{
uniform token info:id = "UsdUVTexture"
asset inputs:file = @textures/m_letter.png@
float2 inputs:st.connect = </Box/Materials/MLetterMaterial/st_texCoordReader.outputs:result>
token inputs:wrapS = "clamp"
token inputs:wrapT = "clamp"
float3 outputs:rgb
float outputs:a
}
}
def Material "LogoMaterial"
{
token outputs:surface.connect = </Box/Materials/LogoMaterial/surfaceShader.outputs:surface>
def Shader "surfaceShader"
{
uniform token info:id = "UsdPreviewSurface"
color3f inputs:diffuseColor.connect = </Box/Materials/LogoMaterial/diffuseColor_opacity_texture.outputs:rgb>
color3f inputs:emissiveColor = (0, 0, 0)
float inputs:metallic = 0
normal3f inputs:normal = (1, 1, 1)
float inputs:opacity.connect = </Box/Materials/LogoMaterial/diffuseColor_opacity_texture.outputs:a>
float inputs:roughness = 1.0
token outputs:surface
float inputs:opacityThreshold = 0.5
}
def Shader "st_texCoordReader"
{
uniform token info:id = "UsdPrimvarReader_float2"
token inputs:varname = "st"
float2 outputs:result
}
def Shader "diffuseColor_opacity_texture"
{
uniform token info:id = "UsdUVTexture"
asset inputs:file = @textures/plane1Name@
float2 inputs:st.connect = </Box/Materials/LogoMaterial/st_texCoordReader.outputs:result>
token inputs:wrapS = "clamp"
token inputs:wrapT = "clamp"
float3 outputs:rgb
float outputs:a
}
}
def Material "PromoMaterial"
{
token outputs:surface.connect = </Box/Materials/PromoMaterial/surfaceShader.outputs:surface>
def Shader "surfaceShader"
{
uniform token info:id = "UsdPreviewSurface"
color3f inputs:diffuseColor.connect = </Box/Materials/PromoMaterial/diffuseColor_opacity_texture.outputs:rgb>
color3f inputs:emissiveColor = (0, 0, 0)
float inputs:metallic = 0
normal3f inputs:normal = (0, 0, 1)
float inputs:occlusion = 1
float inputs:opacity.connect = </Box/Materials/PromoMaterial/diffuseColor_opacity_texture.outputs:a>
float inputs:roughness = 1.0
token outputs:surface
float inputs:opacityThreshold = 0.5
}
def Shader "st_texCoordReader"
{
uniform token info:id = "UsdPrimvarReader_float2"
token inputs:varname = "st"
float2 outputs:result
}
def Shader "diffuseColor_opacity_texture"
{
uniform token info:id = "UsdUVTexture"
asset inputs:file = @textures/promo.png@
float2 inputs:st.connect = </Box/Materials/PromoMaterial/st_texCoordReader.outputs:result>
token inputs:wrapS = "clamp"
token inputs:wrapT = "clamp"
float3 outputs:rgb
float outputs:a
}
}
def Material "BoxMaterial"
{
token outputs:surface.connect = </Box/Materials/BoxMaterial/pbr.outputs:surface>
def Shader "pbr"
{
token info:id = "UsdPreviewSurface"
color3f inputs:diffuseColor = (boxRed, boxGreen, boxBlue)
color3f inputs:emissiveColor = (0.01, 0.01, 0.01)
float inputs:metallic = 0.4
normal3f inputs:normal = (1, 1, 1)
float inputs:occlusion = 1
float inputs:opacity = boxOpacity
float inputs:roughness = 0.3
token outputs:surface
float inputs:opacityThreshold = 0.2
}
}
}
}
А дальше всё просто:
копируем файл и текстуры в отдельную папку;
скриптом на Python считаем правильные позиции вершин;
подменяем в скопированном файле заглушки и подменяем их на рассчитанные позиции и другие параметры;
архивируем и меняем расширение файла.
И вот такую модельку мы получаем в итоге:
Имея базу данных размеров холодильников, телевизоров и прочих крупногабаритных товаров, можно за пару минут сгенерировать несколько тысяч легковесных моделей.
Заключение
Фича успешно работает: при желании, можно найти товары с 3D-моделями и «примерить» у себя дома. Тем более, вы теперь знаете, сколько всего стоит за каждой моделькой.
Спустя примерно месяц после доработок, я узнал, что в Blender версии 3.0.1 в экспериментальном режиме реализовали импорт USDZ. Из интереса поставил beta версию, чтобы проверить — материалы они так и не импортировали! Так что, по сути, моя неидеальная версия конвертера на тот момент оказалась чуточку продвинутей. Сейчас последняя версия Blender умеет и в материалы.
Написать конвертер оказалось проще, чем казалось на первый взгляд. Это был очень интересный опыт — тот редкий случай, когда чем сложнее задача, тем интереснее её решать. Для себя я пришёл к таким выводам:
Не бойтесь сложных задач, они позволяют получить уникальный опыт и позволяют устроить стресс-тест имеющимся навыкам и знаниям.
Изучайте подручные инструменты, зачастую они могут чуть больше, чем кажется. Если бы у меня не получилось окольными путями сконвертировать хотя бы одну модельку, я бы быстро перегорел и ушел с проекта.
Внимательно изучайте инструменты, которые относятся к предметной области. Я случайно наткнулся на библиотеку Pixar, которая помогла мне продвинуться в решении задачи. Без удобных инструментов отладки из этой библиотеки я бы не смог закончить задачу в срок.
Комментарии (4)
MasterMentor
28.06.2023 12:10Хосподе! И это статьи от Яндекс, и работа в Яндекс??! :)
Костыль-мастер (с) Шико Мстоян
Вот туж точно. :)
MasterMentor
28.06.2023 12:10Я бы рекомендовал сменить название статьи на «Как мы костылили обёртку (wrapper) над чужими библиотеками» и поставить заставкой это:
cinme
Раз уж вы начали разбираться в формате USD, то может разумнее было бы добавить поддержку этого формата в приложение, чем писать конвертер?
shiko777 Автор
Конкрентно в моменте стояла задача переконвертировать около 100-150 моделей в уже поддерживаемый формат, найденый мною костыльный вариант позволял это сделать "со всеми удобствами":
удобочитаемый формат данных (все промежуточные результаты, в том числе и вывод usdcat я сразу сохранял для всевозможных проверок что пошло не так)
есть все дебажные данные для проверки (в том числе и саму модельку можно без проблем посмотреть на линуксе)
промежуточная сцена, созданная в blender позволяла быстро проверить, как влияют какие-либо правки на финальный результат
есть возможность удобно вносить дополнительные правки (например, в некоторых случаях были текстуры 4к и больше, что на мобилках приводило к тормозам)
возможность в будущем спокойно править ошибки просто перегенерив модельки, в случае с поддержкой формата приложением цена ошибки выше (придется для определенных версий приложения не отдавать проблемные/неподдерживаемые модельки)
В случае поддержки формата в приложении можно было бы наткнуться на кучу проблем:
нужно еще больше погружаться в работу ArCore и USD, что заняло бы еще больше времени -> больше риски не успеть в сроки
банально неудобно смотреть на промежуточные результаты (сравнивать пример модельки с тем что отрисовалось в приложении на андроиде гораздо сложнее чем сравнить в полноценном 3d редакторе)
нет возможности на ходу быстро вносить правки (те же размеры текстур и тд)
большое количество возможных подводных камней, которые с ходу я мог бы не заметить