Всем привет! Меня зовут Шико, я работаю в Яндекс Маркете в команде 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 (встроенный плагин в бета-версии), только работает он весьма специфично.

Собственно, первая более-менее успешная попытка конвертировать файл вышла по следующему алгоритму:

  1. Импорт файла USDZ в UnrealEngine.

  1. Экспорт в .obj формат.

  1. Попытка применить текстуры методом тыка в Blender.

Чем этот способ не подходил: Unreal Engine довольно тяжёлый, он требует много ресурсов, и в нём неудобно импортировать/экспортировать и редактировать. При этом на некоторых модельках он неправильно импортировал нормали полигонов или UV-координаты, что приводило к странным артефактам.

Озадаченный Геральт
Озадаченный Геральт
Загадочный трицератопс
Загадочный трицератопс

В общем, я столкнулся с тем, что в свободном доступе нет ни одного конвертера, который смог бы перенести модельки из USDZ в любой другой формат.

Снова Blender

Параллельно со всем этим я решил всё-таки ещё раз покопать тот плагин к Blender. Я нашёл несколько ошибок в коде. Получилось пофиксить все краши (или почти все), но только я так и не смог пофиксить ошибку при чтении некоторых данных — на многих модельках неправильно считывалась матрица трансформации.

Попробуйте угадать, что это?

Это XBox One
Это XBox One
А это не первая ревизия PlayStation 5
А это не первая ревизия PlayStation 5

Я начал прочёсывать GitHub и вышел на библиотеку Pixar для работы с USDZ. Оказалось, они предоставили все исходники для работы с этим форматом. На всякий попробовал собрать их. 

И вот тут мне улыбнулась удача. Оказалось, среди библиотек есть несколько интересных утилит. Среди них меня особенно заинтересовал USDView. Она позволяет просмотреть визуально модельку с дополнительной информацией по ней.

Полученную информацию я решил использовать для дебага плагина к Blender. Спустя пару вечеров, проведённых в USDView, питоновском дебаггере и Blender, я выяснил, что автор библиотеки самостоятельно написал код для парсинга бинарного файла. На каком-то из шагов по непонятной мне ошибке (потратил почти 4 дня на эти трансформации!) этот код иногда пропускает одну из множества матриц трансформаций, что и приводит к покорёженным результатам. 

Руками править модельки нереально, учитывая сжатые сроки. Фикс этой проблемы мною был оценен на уровне «вроде изян» по универсальной таблице оценки задачи разработчиками, поэтому решил забросить эту идею.

Я продолжил изучать библиотеку. Тогда у меня возникла первая шальная мысль написать собственный конвертер, изучив кишки библиотеки. Но эта задача тоже выглядела «вроде изян». И только я собрался отказаться от идеи, как я нахожу в этой библиотеке софтину Usdcat.

Как оказалось, Pixar сделали три возможных формата файла:

  • USDC — бинарный файл, который хранит в себе всю инфу по сетке и трансформациям, а также информацию о материалах без текстур.

  • USDA — то же, что и USDC, только в текстовом формате.

  • USD — он может быть как бинарным так и текстовым.

  • USDZ — по сути, это zip-архив, внутри которого есть USDC или USDA файл и текстуры.

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

На этом этапе я собрал Blender из исходников как внешнюю библиотеку для Python, и в качестве эксперимента начал писать прототип конвертера.

Как я писал свой конвертер

Внимание: дальше идёт не очень хороший код от новичка на Python. Уберите впечатлительных разработчиков от экрана.

Процесс конвертации я разбил на несколько этапов:

  1. Распаковка USDZ-файла.

  2. Конвертация USDC в USDA.

  3. Парсинг USDA, преобразование его в более удобочитаемую структуру в памяти.

  4. Обработка структуры, преобразование её в набор команд для Blender для создания сцены с моделькой.

  5. Сохранение в *.blend файл для последующей отладки.

  6. Конвертация сцены в 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)


  1. cinme
    28.06.2023 12:10
    +1

    Раз уж вы начали разбираться в формате USD, то может разумнее было бы добавить поддержку этого формата в приложение, чем писать конвертер?


    1. shiko777 Автор
      28.06.2023 12:10

      Конкрентно в моменте стояла задача переконвертировать около 100-150 моделей в уже поддерживаемый формат, найденый мною костыльный вариант позволял это сделать "со всеми удобствами":

      1. удобочитаемый формат данных (все промежуточные результаты, в том числе и вывод usdcat я сразу сохранял для всевозможных проверок что пошло не так)

      2. есть все дебажные данные для проверки (в том числе и саму модельку можно без проблем посмотреть на линуксе)

      3. промежуточная сцена, созданная в blender позволяла быстро проверить, как влияют какие-либо правки на финальный результат

      4. есть возможность удобно вносить дополнительные правки (например, в некоторых случаях были текстуры 4к и больше, что на мобилках приводило к тормозам)

      5. возможность в будущем спокойно править ошибки просто перегенерив модельки, в случае с поддержкой формата приложением цена ошибки выше (придется для определенных версий приложения не отдавать проблемные/неподдерживаемые модельки)

      В случае поддержки формата в приложении можно было бы наткнуться на кучу проблем:

      1. нужно еще больше погружаться в работу ArCore и USD, что заняло бы еще больше времени -> больше риски не успеть в сроки

      2. банально неудобно смотреть на промежуточные результаты (сравнивать пример модельки с тем что отрисовалось в приложении на андроиде гораздо сложнее чем сравнить в полноценном 3d редакторе)

      3. нет возможности на ходу быстро вносить правки (те же размеры текстур и тд)

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


  1. MasterMentor
    28.06.2023 12:10

    Хосподе! И это статьи от Яндекс, и работа в Яндекс??! :)

    Костыль-мастер (с) Шико Мстоян

    Вот туж точно. :)


  1. MasterMentor
    28.06.2023 12:10

    Я бы рекомендовал сменить название статьи на «Как мы костылили обёртку (wrapper) над чужими библиотеками» и поставить заставкой это: