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

«Hello Triangle» — это первое упражнение, которое разработчик графики делает при изучении нового API, аналогичное упражнению «Hello World» с новым языком.
Они сильно различаются по сложности и продолжительности, от брутальных 1к линий Вулкана к 100 линиям Металла.

Я слышал очень хорошие отзывы и о Metal, и о Swift, поэтому решил попробовать. А пока я под впечатлением. Молодец Эппл!

Другие API, такие как OpenGL, Vulkan или DirectX, имеют действительно хорошую документацию и учебные пособия по всему Интернету, однако, когда я решил начать с Metal, я не мог найти многого. Почти каждое руководство было предназначено для приложений iOS, а документация Apple по-прежнему находится на языке Objective C.
Поэтому я решил задокументировать свое путешествие, чтобы оно могло помочь другим, столкнувшимся с похожими проблемами.

🤷🏼‍♂️ Почему бы не использовать Xcode?

У меня нет другого ответа, кроме как лень. Я просто не хотел полагаться на фреймворк пользовательского интерфейса Swift и изучать его для этого, так как не хочу писать приложения, а просто очень простой игрушечный движок.
Я хотел сосредоточиться на изучении Metal. .

Это означало отсутствие xcodeproj, раскадровки, ручного управления окнами, компиляции шейдеров и т. д.

Кроме того, я пришел из Linux, поэтому я в значительной степени привык работать из терминала (и диспетчер пакетов CLI Swift довольно хорош).

SourceKit LSP также дает очень хорошие возможности для разработки, поэтому я могу использовать свои точно настроенные настройки VSCode/Vim и работать продуктивно с самого начала без необходимости изучать, как использовать новую IDE.

Однако работа с управлением окнами настолько запутана, что я мог бы пересмотреть эту часть в будущем.

После этого «предупреждения» пристегнитесь и приступим!

🛠 Создание проекта

Благодаря интерфейсу командной строки диспетчера пакетов Swift это очень просто и понятно, просто откройте выбранный вами терминал и запустите это:

$ mkdir MetalHelloTriangle
$ cd MetalHelloTriangle
$ swift package init --type executable

Это создаст исполняемый проект Hello World со всем, что вам нужно.
Протестируйте его с помощью:

$ swift run

Далее, поскольку Metal доступен только для устройств Apple, нам нужно добавить ограничение на платформы, на которых он будет работать.
Добавьте это в Package.swift после имени. поле (создается автоматически, оно уже должно существовать в корневом каталоге проекта)

// Package.swift
let package = Package(
    name: "MetalHelloTriangle",
    platforms: [ .macOS(.v10_15) ], // NEW LINE
    dependencies: [
...

Я не планирую запускать это на каком-либо устройстве, кроме моего ноутбука, поэтому я установил только macOS в качестве платформы.

🪟 Открытие (простого) окна

Здесь началась первая проблема с неиспользованием Xcode. Это был беспорядок. На это было потрачено 90% времени разработки.
Хотел бы я найти что-то похожее на GLFW, но не смог.
В итоге я положился на AppDelegate/ Фреймворк ViewController, следующий за этой записью в блоге и первой частью этой другой.

Сначала мы создадим новый файл AppDelegate.swift, он будет содержать, как вы уже догадались, класс AppDelegate, который будет создавать и владеть окном.

Мы начнем с импорта Cocoa и создания класса

// AppDelegate.swift
import Cocoa

let WIDTH  = 800
let HEIGHT = 600

class AppDelegate: NSObject, NSApplicationDelegate
{
    private var mWindow: NSWindow?

    func applicationDidFinishLaunching(_ aNotification: Notification)
    {
        // This will be called once when we run the engine
    }
}

Тем временем в main.swift мы устанавливаем делегат приложения:

// main.swift
import Cocoa

let delegate = AppDelegate()
NSApplication.shared.delegate = delegate
_ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv)

Время создать само окно

// AppDelegate.swift
func applicationDidFinishLaunching(_ aNotification: Notification)
{
    let screenSize = NSScreen.main?.frame.size ?? .zero

    let rect = NSMakeRect((screenSize.width  - CGFloat(WIDTH))  * 0.5,
                          (screenSize.height - CGFloat(HEIGHT)) * 0.5,
                          CGFloat(WIDTH),
                          CGFloat(HEIGHT))

    mWindow = NSWindow(contentRect: rect,
                       styleMask:   [.miniaturizable,
                                     .closable,
                                     .resizable,
                                     .titled],
                        backing:    .buffered,
                        defer:      false)

    mWindow?.title = "Hello Triangle"
    mWindow?.makeKeyAndOrderFront(nil)
}

Хорошо, теперь у нас есть пустое окно, но нам нужно иметь возможность рисовать в нем.

Затем мы добавляем контроллер представления содержимого окна.
Создайте новый класс ViewController и добавьте его экземпляр в mWindow перед установкой это как ключ:

// AppDelegate.swift
class ViewController : NSViewController
{
    override func loadView()
    {
        let rect = NSRect(x: 0, y: 0, width: WIDTH, height: HEIGHT)
        view = NSView(frame: rect)
        view.wantsLayer = true
        view.layer?.backgroundColor = NSColor.red.cgColor
    }
}
// AppDelegate.applicationDidFinishLaunching
window?.title = "Hello Triangle!"
window?.contentViewController = ViewController() // NEW LINE
window?.makeKeyAndOrderFront(nil)

Запустив это, мы получим окно с красивым красным фоном.

Мы позволим Metal обрабатывать цикл обновления, а для этого нам потребуется заменить представление контроллера представления на MTKView.
Для этого нам потребуется соответствующий графический процессор.
Добавьте нового члена mDevice в AppDelegate

// AppDelegate.swift
import MetalKit // NEW LINE

class AppDelegate: NSObject, NSApplicationDelegate
{
    private var window:   NSWindow?
    private var device:   MTLDevice? // NEW LINE

    func applicationDidFinishLaunching(_ aNotification: Notification)
    {
        ...
        window?.makeKeyAndOrderFront(nil)

        mDevice = MTLCreateSystemDefaultDevice()   // NEW LINE
        if mDevice == nil { fatalError("NO GPU") } // NEW LINE
    }
    ...

Для обработки MTKView мы создадим новый класс, расширяющий MTKViewDelegate. Это будет наш Renderer.swift

// Renderer.swift
import MetalKit

class Renderer : NSObject
{
    public var mView: MTKView

    public init(view: MTKView)
    {
        mView = view
        super.init()
        mView.delegate = self
    }

    private func update()
    {
        // Uncomment this to check it's working
        // print("Hello frame!")
    }
}

extension Renderer: MTKViewDelegate
{
    public func draw(in view: MTKView)
    {
        // Called every frame
        self.update()
    }

    public func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize)
    {
        // This will be called on resize
    }
}

Теперь мы создаем MTKView и заменяем им ViewController.

// AppDelegate.swift
...
    private var mRenderer: Renderer? // NEW LINE

    func applicationDidFinishLaunching(_ aNotification: Notification)
    {
        ...
        mDevice = MTLCreateSystemDefaultDevice()
        if mDevice == nil { fatalError("NO GPU") }

        // NEW BLOCK START
        let view  = MTKView(frame: rect, device: mDevice)
        mRenderer = Renderer(view: view)

        mWindow?.contentViewController?.view = view
        // NEW BLOCK END
    }

Готово!
Кстати, этот последний шаг удалит красный фон, потому что мы больше не используем исходное представление ViewController. Вероятно, это можно было бы сделать лучше, но я не хочу на этом останавливаться.

Эта реализация действительно хрупкая (по какой-то причине она не попадает поверх других окон, и ее закрытие не останавливает приложение), но она справляется со своей задачей. В конце концов, я здесь ради графического API, и ничего больше.

Я был бы признателен за любую помощь/отзыв в комментариях.

🔺 Отрисовка простого треугольника

Я не хочу, чтобы эта статья была слишком длинной, поэтому я предполагаю, что вы уже знаете, как работает общий графический конвейер.
Если вы хотите получить подробное объяснение, вы можете перейти к Apple docs. .
Мне тоже очень пригодилась Эта другая статья.

Начнем с добавления данных вершин треугольника и помещения их в буфер вершин:

// Renderer.swift
// NEW BLOCK START
let VERTEX_DATA: [SIMD3<Float>] =
[
    [ 0.0,  1.0, 0.0],
    [-1.0, -1.0, 0.0],
    [ 1.0, -1.0, 0.0]
]
// NEW BLOCK END

class Renderer : NSObject
{
    ...
    private func update()
    {
        // NEW BLOCK START
        let dataSize     = VERTEX_DATA.count * MemoryLayout.size(ofValue: VERTEX_DATA[0])
        let vertexBuffer = mView.device?.makeBuffer(bytes:   VERTEX_DATA,
                                                    length:  dataSize,
                                                    options: [])
        // NEW BLOCK END
    }
}

Конвейер рендеринга

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

// Renderer.swift
class Renderer : NSObject
{
    private var mPipeline: MTLRenderPipelineState
    ...
    public init(view: MTKView)
    {
        mView = view

        // NEW BLOCK START
        let pipelineDescriptor = MTLRenderPipelineDescriptor()
        pipelineDescriptor.colorAttachments[0].pixelFormat = mView.colorPixelFormat
        // TODO: pipelineDescriptor.vertexFunction                  = 
        // TODO: pipelineDescriptor.fragmentFunction                = fragmentFunction
        // TODO: pipelineDescriptor.vertexDescriptor                = vertDesc

        guard let ps = try! mView.device?.makeRenderPipelineState(descriptor: pipelineDescriptor) else
        {
            fatalError("Couldn't create pipeline state")
        }
        mPipeline = ps
        // NEW BLOCK END

        super.init()
        mView.delegate = self
    }
}

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

Шейдеры

Metal использует собственный язык затенения, называемый MLSL, с расширением файла .metal.
В этом примере мы сохраним функции вершин и фрагментов в одном файле и просто закрасим все красным. .

Создайте новый каталог Sources/Shaders и файл HelloTriangle.metal в нем.

// Sources/Shaders/HelloTriangle.metal
#include <metal_stdlib>
using namespace metal;

struct VertexIn
{
    float3 position [[ attribute(0) ]];
};

vertex
VertexOut vertex_main(VertexIn vert [[ stage_in ]])
{
    return float4(vert.position, 1.0f);
}

fragment
float4 fragment_main()
{
    return float4(1,0,0,1);
}

Metal использует предварительно скомпилированные шейдеры в так называемых библиотеках.
Чтобы скомпилировать их без Xcode, нам нужно выполнить пару команд.

Сначала скомпилируйте .metal в .air файлов.

xcrun metal -c HelloTriangle.metal -o HelloTriangle.air

Во-вторых, упакуйте .air файлов в один .metallib

xcrun metal HelloTriangle.air -o HelloTriangle.metallib

Подробнее о ручной компиляции шейдеров и библиотек Metal здесь.

Теперь, когда у нас есть библиотека, нам просто нужно ее загрузить.

// Renderer.swift
import MetalKit

let SHADERS_DIR_LOCAL_PATH        = "/Sources/Shaders" // NEW LINE
let DEFAULT_SHADER_LIB_LOCAL_PATH = SHADERS_DIR_LOCAL_PATH + "/HelloTriangle.metallib" // NEW LINE

...

public init(view: MTKView)
{
    mView = view

    // NEW BLOCK START
    let shaderLibPath = FileManager.default
                                    .currentDirectoryPath +
                        DEFAULT_SHADER_LIB_LOCAL_PATH

    guard let library = try! mView.device?.makeLibrary(filepath: shaderLibPath) else
    {
        fatalError("No shader library!")
    }
    let vertexFunction   = library.makeFunction(name: "vertex_main")
    let fragmentFunction = library.makeFunction(name: "fragment_main")
    // NEW BLOCK END

    let pipelineDescriptor = MTLRenderPipelineDescriptor()
    pipelineDescriptor.colorAttachments[0].pixelFormat = mView.colorPixelFormat
    pipelineDescriptor.vertexFunction                  = vertexFunction // NEW LINE
    pipelineDescriptor.fragmentFunction                = fragmentFunction // NEW LINE
}

Убедитесь, что имя в library.makeFunction соответствует тому, которое вы хотите вызвать для этой стадии в шейдере.

Дескрипторы вершин

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

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

В данном случае мы передаем только позицию, то есть это 1 аргумент формата float3.

// Renderer.swift
...
let vertexFunction   = library.makeFunction(name: "vertex_main")
let fragmentFunction = library.makeFunction(name: "fragment_main")

// NEW BLOCK START
let vertDesc = MTLVertexDescriptor()
vertDesc.attributes[0].format      = .float3
vertDesc.attributes[0].bufferIndex = 0
vertDesc.attributes[0].offset      = 0
vertDesc.layouts[0].stride         = MemoryLayout<SIMD3<Float>>.stride
// NEW BLOCK END
...

Очередь команд

Наконец, последний шаг, нам нужно сообщить графическому процессору команды, которые мы хотим, чтобы он выполнял. Мы делаем это с помощью CommandEncoders, сгруппированных в CommandBuffers, выстроенных в очередь команд.

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

// Renderer.swift
...
public class Renderer : NSObject
{
    public  var mView:         MTKView

    private let mPipeline:     MTLRenderPipelineStat
    private let mCommandQueue: MTLCommandQueue // NEW LINE

    public init(view: MTKView)
    {
        mView = view

        // NEW BLOCK START
        guard let cq = mView.device?.makeCommandQueue() else
        {
            fatalError("Could not create command queue")
        }
        mCommandQueue = cq
        // NEW BLOCK END
        ...
    }
    ...

Затем в методе update мы настраиваем команды.

// Renderer.swift
...
private func update()
{
    let dataSize     = VERTEX_DATA.count * MemoryLayout.size(ofValue: VERTEX_DATA[0])
    let vertexBuffer = mView.device?.makeBuffer(bytes:   VERTEX_DATA,
                                                length:  dataSize,
                                                options: [])
    // NEW BLOCK START
    let commandBuffer  = mCommandQueue.makeCommandBuffer()!

    let commandEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: mView.currentRenderPassDescriptor!)
    commandEncoder?.setRenderPipelineState(mPipeline)
    commandEncoder?.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
    commandEncoder?.drawPrimitives(type: .triangle,
                                   vertexStart: 0,
                                   vertexCount: 3,
                                   instanceCount: 1)
    commandEncoder?.endEncoding()

    commandBuffer.present(mView.currentDrawable!)
    commandBuffer.commit()
    // NEW BLOCK END
}

🎨 Добавляем немного цвета

Хорошо, теперь у нас есть красивый красный треугольник. Давайте зададим ему цвет.
Во-первых, добавим цвет к данным вершины:

// Renderer.swift
let VERTEX_DATA: [SIMD3<Float>] =
[
    // v0
    [ 0.0,  1.0, 0.0 ], // position
    [ 1.0,  0.0, 0.0 ], // color
    // v1
    [-1.0, -1.0, 0.0 ],
    [ 0.0,  1.0, 0.0 ],
    // v2
    [ 1.0, -1.0, 0.0 ],
    [ 0.0,  0.0, 1.0 ]
]

Теперь нам пригодятся дескрипторы вершин. Сообщите им об изменениях в структуре данных.

// Renderer.swift
let vertDesc = MTLVertexDescriptor()
vertDesc.attributes[0].format      = .float3
vertDesc.attributes[0].bufferIndex = 0
vertDesc.attributes[0].offset      = 0
vertDesc.attributes[1].format      = .float3 // NEW LINE
vertDesc.attributes[1].bufferIndex = 0 // NEW LINE
vertDesc.attributes[1].offset      = MemoryLayout<SIMD3<Float>>.stride // NEW LINE
vertDesc.layouts[0].stride         = MemoryLayout<SIMD3<Float>>.stride * 2 // LINE MODIFIED!

Шаг макета теперь в два раза больше SIMD3<Float>, потому что каждая вершина имеет 2 float3 данных.

И, наконец, шейдер нуждается в некоторых обновлениях.
Добавьте новую структуру, которая будет служить выходными данными этапа вершин и входными данными этапа фрагмента.
Также добавьте новый атрибут color в уже существующий VertexIn.

// HelloTriangle.metal
#include <metal_stdlib>
using namespace metal;

struct VertexIn
{
    float3 position [[ attribute(0) ]];
    float3 color    [[ attribute(1) ]];
};

struct VertexOut
{
    float4 position [[ position ]];
    float3 color;
};

vertex
VertexOut vertex_main(VertexIn vert [[ stage_in ]])
{
    VertexOut out;
    out.position = float4(vert.position, 1.0f);
    out.color    = vert.color;
    return out;
}

fragment
float4 fragment_main(VertexOut frag [[ stage_in ]])
{
    return sqrt(float4(frag.color, 1.0));
}

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

И вуаля!

Отсюда небо является пределом. Добавьте движение, проекции, текстуры, источники света, проходы рендеринга и т. д. и т. д.

Дайте мне знать о любых проблемах/улучшениях/отзывах в комментариях и удачного кодирования! :D

📚 Ссылки

Первоначально опубликовано на https://dev.to 6 апреля 2021 г.