Как я написал конвертер 3D-моделей из подручных средств

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

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

Источник: https://habr.com/ru/companies/yandex/articles/743496/


Интересные статьи

Интересные статьи

Летом этого года были приняты законы, устанавливающие административную и уголовную ответственность для операторов связи за отсутствие фильтрации интернет-трафика с помощью технических средств противод...
Методический разбор для специалистов DataScience по применению критерия Эппса-Палли для проверки нормальности распределения средствами python
С выпуском Visual Studio 16.10 появился новый механизм анализа для профилировщика производительности, при этом .NET Object Allocation Tool (средство выделения объектов .N...
Прежде чем приобрести дезинфектант для обработки помещений, необходимо учесть ряд моментов, которые помогут лучше сориентироваться во всем многообразии средств и не совершить ошибку. ...
Эту статью я задумал еще на втором курсе, когда впервые решил перейти с Word'а на LaTeX. В конце третьего курса я ее начал, и, наконец, после защиты диплома я нашел в себе силы ее дописат...