Search
Write a publication
Pull to refresh

Рисуем чайник в Metal

Level of difficultyEasy
Reading time10 min
Views912

Сегодня научимся рендерить 3D модель чайника при помощи Metal API. В процессе познакомимся с устройством модели, настроим пайплайн Metal и напишем шейдеры. Поиграться с кодом рендера можно в демо проекте.

Загружаем чайник

Для примера возьмем готовую модель чайника в формате .obj. В начале нужно получить вершины нашей модели и в этом нам поможет фреймворк Model I/O. Добавим модель в бандл проекта и при помощи класса MDLAsset создадим специальное представление модели — ассет. Также нам понадобятся два компонента: аллокатор памяти MTKMeshBufferAllocator и MDLVertexDescriptor.

MDLVertexDescriptor диктует, какие данные о вершинах попадут в вершинный шейдер. Мы будем использовать два компонента для каждой вершины: позицию и вектор нормали. Для каждого компонента создадим объект класса MDLVertexAttribute и присвоим его в соответствующий индекс массива attributes у экземпляра MDLVertexDescriptor:

let vertexDescriptor = MDLVertexDescriptor()
vertexDescriptor.attributes[0] = MDLVertexAttribute(
    name: MDLVertexAttributePosition,
    format: .float3,
    offset: 0,
    bufferIndex: 0
)

vertexDescriptor.attributes[1] = MDLVertexAttribute(
    name: MDLVertexAttributeNormal,
    format: .float3,
    offset: MemoryLayout<Float>.size * 3,
    bufferIndex: 0
)

vertexDescriptor.layouts[0] = MDLVertexBufferLayout(stride: MemoryLayout<Float>.size * 6)

Конструктор MDLVertexAttribute принимает 4 аргумента:

  • Имя атрибута

  • Тип данных

  • Смещение

  • Индекс буфера

Мы будем использовать две предопределенные константы имени:

  • MDLVertexAttributePosition — для координаты вершины.

  • MDLVertexAttributeNormal — для вектор нормали поверхности.

Тип данных для каждого атрибута предопределен заранее. Для вектора позиции и нормали это тип float3 — то есть массив из трех чисел float.

Смещение — это позиция каждого атрибута относительно начала буфера. Логика здесь простая. Значения лежат в памяти друг за другом и у нашего первый атрибута смещение равно 0. У второго будет смещение в 3 байта потому, что он лежит сразу после первого с размером в 3 байта.

Индекс буфера определяет к какому вершинному буферу относится этот атрибут. Про вершинный буфер мы поговорим далее.

Данные о вершинах

Для настройки пайплайна нам нужны данные о вершинах модели в “родном” для Metal формате — в виде экземпляра класса MTKMesh. Для этого воспользуется статическим методом newMeshes у класса MTKMesh, который возвращает массив MTKMesh. Он принимает в качестве аргументов экземпляры MDLAsset и MTLDevice.

MTKMesh — это контейнер с данными о модели. Его главный компонент — это вершинный буфер, где хранятся вершины модели. Также MTKMesh содержит индексный буфер, внутри которого лежат порядковые номера вершин. Индексный буфер определяет, как комбинировать вершины для получения полной модели. Metal использует вершинный и индексный буфер для отрисовки модели.

Перед тем, как дальше погружаться в настройку пайплайна мы должны понять, как поместить модель внутрь виртуальной сцены.

Координатные пространства

Для размещения модели в виртуальном сцене нужно познакомиться с таким понятием, как система координат (coordinate space). Координатная система описывает способ определения положения и перемещения координат вершин. Мы будем работать с 4-мя системами:

  1. Local space — координаты вершин модели относительно нулевой точки (origin) самой модели

  2. World space — координаты вершин модели на сцене, в виртуальном мире относительно нулевой точки

  3. View space — координаты вершин модели перед камерой

  4. Clip space — координаты вершин модели с перспективной проекцией

Переход между системами координат
Переход между системами координат

Переходить из одной системы в другую мы будем с помощью матриц трансформации. Они проецируют координаты вершин из одной системы в другую. Для начала нужно перевести нашу модель из локальной системы координат (local space) в мировую систему координат (world space) при помощи модельном матрицы (model matrix). Мы можем вращать модель, масштабировать и перемещать в любое место на сцене. Код для генерации матриц лежит в файле Utils.swift внутри демо проекта.

Дальше необходимо разобраться с камерой. Для имитации камеры нужно перевести координаты нашей модели во view space. Для этого используется матрица наблюдателя (view matrix). Она проецирует координаты вершин из world space во view space. Например, изменяя view матрицу мы можем смещать нашу “камеру” по координате z.

Осталось разобраться с последним переходом из view в clip space. По сути это преобразование 3D координат в 2D координаты точек на экране. Мы будем использовать perspective projection matrix. Она позволяет достичь эффекта, при котором объекты на дальнем плане кажутся меньше.

Для построение perspective projection matrix нам нужно узнать про несколько понятий:

  • Field of view

  • Near z (near plane)

  • Far z (far plane)

Field of view (FOV) — это область виртуального мира, которую мы видим на дисплее. Обознается как угол и задается в радианах. В нашем примере мы будем использовать вертикальный fov. Чем больше fov, тем больше мы видим.

Значения near z и far z влияют на то, какие вершины мы отобразим на экране. Любые вершины с z меньшим чем near z, и большим, чем far z не будут отображены.

В итоге, пройдя через три системы координат, наша модель переместилась в clip space (или в normal device coordinates). В этой системе все координаты должны лежать в диапазоне от -1 до 1 и все вершины вне этого диапазона не будут отображены на экране.

Полученные матрицы транформации нужно передать в вершинный шейдер. Для этого создадим структуру Uniforms в которой будем хранить матрицы:

struct Uniforms {
    var modelViewMatrix: float4x4
    var projectionMatrix: float4x4
}

Для удоства мы перемножим model и view матрицы и сохраним результат в поле modelViewMatrix. Матрицу перспективы положим рядом в поле projectionMatrix.

Затем мы должны передать наши матрицы внутрь шейдера. Для этого нужно сконфигурировать пайплайн Metal.

Конфигурируем пайплайн

Вспомним основные компоненты пайплайна Metal для рендеринга. Сначала нужно получить MTLDevice — это представление GPU, который установлен на девайсе. Следом создадим экземпляры MTLCommandQueue и MTLCommandBuffer. В них вызовы Metal для рисования конвертируются в команды понятные GPU. Далее создаем энкодер команд при помощи дескриптора (render pass descriptor) и метода makeRenderCommandEncoder у буфера команд. Этот дескриптор определеляет, куда мы будем выводить результат рендеринга. В нашем случае это вьюшка MTKView.

Энкодер команд требует для своей работы экземпляр MTLRenderPipelineState. Для его создания нам понадобиться создать экземпляр класса  MTLRenderPipelineDescriptor.

let descriptor = MTLRenderPipelineDescriptor()
descriptor.vertexFunction = metalLibrary.makeFunction(name: "vertexShader")
descriptor.fragmentFunction = metalLibrary.makeFunction(name: "fragmentShader")
descriptor.colorAttachments[0].pixelFormat = .bgra8Unorm
descriptor.depthAttachmentPixelFormat = .depth32Float
descriptor.vertexDescriptor = vertexDescriptor

В MTLRenderPipelineDescriptor мы указываем, какие шейдеры запускать и какой vertex descriptor использовать. Форматы color buffer и depth buffer тоже указываются здесь.

Далее при помощи pipeline descriptor и метода makeRenderPipelineState объекта MTLDevice наконец получим экземпляр MTLRenderPipelineState. После этого передадим MTLRenderPipelineState в метод setRenderPipelineState энкодера команд, как этого требует Metal. Обязательно нужно сделать это перед добавлением команд рисования.

Команды рисования

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

Для начала отправим нашу структуру с типом Uniforms внутрь вершинного шейдера. Для этого передадим ссылку на нее, размер и индекс атрибута шейдера в метод setVertexBytes энкодера команд. Индекс атрибута нужен, чтобы получить нашу структуру внутри вершинного шейдера. В нашем примере индекс равняется 1.

После этого вернемся к нашим MTKMesh, которые мы сохранили в самом начале в свойстве meshes внутри рендера. Запустим цикл по meshes и внутри него начнем постепенно создавать команды отрисовки. Но сначала получим вершинные буферы.

Вершинные буферы хранятся в массиве vertexBuffers у объекта MTKMesh. В нашем примере есть только один буфер. Передадим его в вершинный шейдер при помощи метода setVertexBuffer у энкодера команд. Метод принимает сам буфер, смещение и индекс в таблице аргументов. В нашем примере индекс равен 0. Важно, чтобы он совпадал с индексом внутри экземпляра MDLVertexDescriptor.

Теперь нам нужно получить индексный буфер из свойства indexBuffer экземпляра класса MTKSubmesh. А сами экземпляры MTKSubmesh мы получим из массива submeshes в MTKMesh. Далее запустим цикл по submeshes и внутри него уже будем вызывать команды отрисовки.

У экземпляра MTLCommandEncoder есть несколько методов для создания команд отрисовки. Они кодируют инструкции отрисовки примитивов в понятный для GPU формат. Нас интересует только один конкретный метод drawIndexedPrimitives, который принимает 5 аргументов:

  1. Индексный буфер

  2. Смещение внутри индексного буфера 

  3. Тип данных 

  4. Количество индексов внутри индексного буфера и 

  5. Тип примитива для отрисовки

Все значения для них можно найти внутри объекта MTKSubmesh:

for submesh in mesh.submeshes {
    let indexBuffer = submesh.indexBuffer

    commandEncoder.drawIndexedPrimitives(
        type: submesh.primitiveType,
        indexCount: submesh.indexCount,
        indexType: submesh.indexType,
        indexBuffer: indexBuffer.buffer,
        indexBufferOffset: indexBuffer.offset
    )
}

Далее нам нужно завершить настройку и запустить пайплайн. Для этого вызовем метод endEncoding у энкодера команд, а затем методы present и commit у буфера команд. 

Разбираемся с шейдерами

Наш следующий этап — это написание шейдеров. Без них никакой магии не случится. Нам нужны два шейдера — вершиный и фрагментный. Сперва разберемся с вершинным шейдером.

Вершинный шейдер с именем vertexShader отвечает за подготовку вершин. Он принимает два параметра — первый с именем vertexInput, типом Vertex и атрибутом [[stage_in]] и второй с именем uniforms, типом Uniforms и атрибутом [[buffer(1)]] и возвращает структуру типа FragmentData. Коротко рассмотрим каждый из этих типов.

Структура с типом Vertex содержит данные для вершины в трех полях: position, normal. При помощи [[stage_in]] мы указываем, что данные нужно получать с привязкой к vertex descriptor, в который мы задавали атрибуты с именами MDLVertexAttributePosition, MDLVertexAttributeNormal. Для этого структура Vertex должна содержать поле нужного типа данных для каждого атрибута. Также рядом с каждым полем задан атрибут [[attribute(n)]] где n — это индекс атрибута в vertex descriptor.

Структура типа Uniforms повторяет ту, которую мы использовали внутри нашего рендера. Типы данных и порядок полей должны совпадать так как Metal напрямую транслирует данные из структуры на Swift в структуру на языке шейдеров.

Когда мы разобрались с входными параметрами перейдем к выходному параметру типа FragmentData с полем position и атрибутом [[position]] и полем normal. Атрибут [[position]] указывает на то, что в помеченном поле содержится позиция вершины.

Нам нужно создать экземпляр FragmentData и вернуть его из нашего вершинного шейдера. В поле position сохраним координаты вершины, преобразованные в normal device coordinates путем последовательного перемножения projectionMatrix, modelViewMatrix из uniforms и position из vertexInput. В поле normal положим вектор нормали, умноженный на modelViewMatrix.

vertex FragmentData vertexShader(Vertex vertexInput [[stage_in]],
                                 constant Uniforms &uniforms [[buffer(1)]]) {
    FragmentData fragmentData;
    fragmentData.position = uniforms.projectionMatrix * uniforms.modelViewMatrix
        * float4(vertexInput.position, 1);
    fragmentData.normal = uniforms.modelViewMatrix
        * float4(vertexInput.normal, 0);
    return fragmentData;
}

Структура типа FragmentData из вершинного шейдера дальше передается как аргумент с именем fragmentInput во фрагментный шейдер fragmentShader. Для вычисления цвета фрагмента мы будем использовать вектор нормали из поля normal внутри fragmentInput и функции normalize и abs.

fragment float4 fragmentShader(FragmentData fragmentInput [[stage_in]]) {
    float3 normal = abs(normalize(fragmentInput.normal.xyz));
    return float4(normal, 1);
}

Depth testing

Depth testing помогает нам получить более реалистичную картинку. Без него мы можем наблюдать части модели, которые скрыты от нас, например, дно чайника.

Видны части чайника, скрытые от глаз наблюдателя
Видны части чайника, скрытые от глаз наблюдателя

Во время depth testing определяются фрагменты, которые скрыты за другими фрагментами. Такие фрагменты не нужно рендерить, ведь мы их не видим напрямую. В нашем примере это позволит скрыть заднюю стороны чайника. 

В depth testing участвуют все фрагменты. У каждого берется значение координаты z, которое увеличивается по мере удаления от камеры. Координата z сравнивается со значением внутри специального буфера глубины (depth buffer или z-buffer) для тестируемого фрагмента. Если z координата вершины меньше значения внутри буфера глубины, тогда в буфер записывается новое значение. Затем в color buffer записывается цвет этого фрагмента. Если значение больше, то фрагмент игнорируется и color buffer не меняется. В итоге в color buffer останутся только видимые цвета. При помощи depth testing мы определили все фрагменты, которые не перекрыты другими фрагментами по оси z. Разберемся с примером работы depth testing на простом пример с двумя треугольниками:

Два треугольника без depth testing
Два треугольника без depth testing

На картинке изображены красный и белый треугольники. Значение координаты z красного треугольника 0.2, а белого 0.6. Чем меньше значение, тем ближе объект к камере.

Мы видим, что красный треугольник перекрывает часть белого и его не должно быть видно целиком. Но белый треугольник нарисован полностью, как-будто он находится ближе к камере. Добиться эффекта перекрытия нам поможет depth testing. Включим его и увидим только часть белого треугольника:

Два треугольника с включенным depth testing
Два треугольника с включенным depth testing

Мы выяснили, для чего нужен depth testing и depth buffer на примере простых треугольников. Тоже самое применимо и к нашей модели чайника.

Для добавления depth testing в наш пайплайн нужно сконфигурировать depth stencil state. В этом нам поможем MTLDepthStencilDescriptor и метод makeDepthStencilState у MTLDevice. Метод makeDepthStencilState возвращает объект, скрытый за протоколом MTLDepthStencilState. Далее мы передаем этот объект в метод setDepthStencilState нашего энкодера команд. Таким образом мы активировали depth testing для наших флагментов. Запустим наш проект еще раз и увидим только видимые части чайника.

Итоги

Мы прошли путь от загрузки данных о вершинах в память до отображения модели на экране. Сперва познакомились с работой фреймворка Model I/O, который сгенерировал для нас вершинный и индексный буфер из файла модели. Затем мы создали vertex descriptor для удобной работы с данными в вершинном шейдере. Также подробно рассмотрели представление модели в четырех координатных пространствах — local, world, view, clip. Затем при помощи матриц трансформации переместили чайник из local space в clip space. Плюсом для реализма добавили перспективную проекцию и настроили depth testing.

Теперь чайник на экране выглядит как настоящий. Осталось только настроить правильное освещение сцены. Но этим мы займемся в следующий раз, а пока же насладимся результатом. Спасибо за внимание!

Полезные ссылки:
Демо
https://developer.apple.com/documentation/Metal/
https://metalbyexample.com

Tags:
Hubs:
+3
Comments1

Articles