«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
📚 Ссылки
- NSWindow без раскадровки
- Визуализация графического контента с использованием фреймворка MetalKit
- Создание библиотеки с помощью инструментов командной строки Metal
- Вершинные данные и вершинные дескрипторы
Первоначально опубликовано на https://dev.to 6 апреля 2021 г.