Рисуем треугольник с помощью Metal
Введение
В этой статье мы будем рисовать треугольник с помощью графического API от Apple, под названием Metal. В качестве языка я буду использовать Swift. В сети статей по-данному апи используя swift нет на русском языке (или я плохо искал). В итоге получим такой результат:

За основу взяты уроки и книги на английском языке и как можно более свежие. Но я буду все писать на старом ПО (то которое в наличии). Вот что имеем для старта:
- MacBook Pro mid2012 (Intel Core i5-3210m, с интегрированным видео Intel HD4000)
- macOS 10.15
- Xcode 12.4
- Swift 5.3
- Metal 2.2
Графикой я увлекаюсь уже длительное время, но начинал с OpenGL на Windows используя С++. И только недавно увлекся языком Swift и программированием под устройства Apple и решил на нем тоже заняться 3д графикой. Использовать OpenGL и С++ не хотелось, да и на маке OpenGL ограничен версией 4.1, так что все привычные мне вкусности не доступны, а так же создать окно на C++ без сторонних библиотек или написания прослоек на Objective-C++ не представляется возможным.
OpenGL довольно простой и понятный апи, в отличие от тех же Vulkan и DirectX 12, которые слишком низкоуровневые, сложные и многословные для простых задач. Про metal до недавнего времени я был такого же мнения, пока не начал его изучать. Оказывается у него есть "упрощенная версия", представляющая собой MetalKit, то есть набор служебных классов, который позволяет создавать приложения metal быстрее и проще. В итоге использование данного апи становится не сложнее шейдерного OpenGL. От Вас потребуется только знание языка Swift.
Создание проекта и окна
В качестве gui framework я буду использовать Storyboard + AppDelegate, вместо актуального на данный момент SwiftUI. Я хочу охватить наибольшее количество устройств, а у SwiftUI (с жизненным циклом SwiftUI) требования macOS 11 и iOS 13 (телефон у меня на 12, так что тестировать негде будет) и код будет сложнее. Потом как-нибудь перепишу на SwiftUI + AppDelegate. Под iOS (используя UIkit, тот же Storyboard) данная статья должна подходить с минимумом правок.
Откроем Xcode и создадим проект (Create a new Xcode project)

Теперь выберем шаблон macOS и App, нажмем Next

Настроим проект, напишем имя продукта hello-triangle, идентификатор организации ru.learn-metal, интерфейс Storyboard, жизненный цикл Appkit App Delegate, язык Swift и нажмем Next. Потом выберем и сохраним проект на диске.

После этого откроется окно проекта с некоторым кодом, вот точка входя в нашу программу (файл AppDelegate.swift), здесь ничего трогать не будем.
| Swift | 1
2
3
4
5
6
7
8
9
10
11
12
13
| import Cocoa
@main
class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ aNotification: Notification) {
// Insert code here to initialize your application
}
func applicationWillTerminate(_ aNotification: Notification) {
// Insert code here to tear down your application
}
} |
|
В файле ViewController.swift оставим только один метод viewDidLoad
| Swift | 1
2
3
4
5
6
7
| import Cocoa
class ViewController: NSViewController {
override func viewDidLoad() {
super.viewDidLoad()
}
} |
|
Теперь настроим визуальную часть. Начнем с размера окна. Для этого будем использовать Interface Builder, в списке файлов проекта есть файл Main.storyboard. Тыкнем по нему 2 раза левой кнопкой мыши, откроется графический редактор интерфейсов. Для того чтобы задать размер окна надо изменить размер не окна, а View, которое находится на этом окне, окно само подстраивается под размер элементов, которые на нем находятся. Для этого откроем View Controller Scene -> View Controller и выберем View и в свойства в правой менюшке (со значком треугольника) заполним поля Width и Height значениями 800 и 600.

Теперь изменим название окна. Выберем на этот раз Window Controller Scene -> Window Controller -> Window и в том же меню справа, но в разделе со значком в виде ползунков изменим значение поля Title на Hello Triangle. Имя компонента в иерархии тоже поменяется. (на скриншоте имя уже изменено)

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

И последнее, что сделаем в создании окна, это поменяем тип для View на тот в который будем рисовать и привяжем его к полю класса.
Начнем с создания поля класса, откроем файл ViewController.swift и добавим в него строчку @IBOutlet var mtkView: MTKView! перед методом viewDidLoad. И импортируем MetalKit в начале файла строчкой import MetalKit. Можно добавить и после, но сначала поля, а потом методы выглядит более эстетично и практично. В итоге файл будет выглядеть так:
| Swift | 1
2
3
4
5
6
7
8
9
10
| import Cocoa
import MetalKit
class ViewController: NSViewController {
@IBOutlet var mtkView: MTKView!
override func viewDidLoad() {
super.viewDidLoad()
}
} |
|
Вернемся к редактированию Main.storyboard. Для начала поменяем тип нашего View с NSView на MTKView. Выберем наш View (View Controller Scene -> View Controller -> View). Потом в меню справа, значок похожий на газету, список Class в котором поменяем NSView на MTKView (пишем прямо руками и жмем Enter, так как в предложенных вариантах не будет нужного значения).

Теперь осталось связать наш интерфейс с кодом. В центральной области окна перетащим наш View вниз чтобы открылся синий круг с белым квадратом (при изменении размера элементы наехали друг на друга и получилась куча). Далее в правом меню со значком круг с точкой посередине из круга справа от Referencing Outlets -> New Referencing Outlet, левой кнопкой мыши (зажав и не отпуская) тянем линию к синему кругу с белым квадратом в центральной части экрана, после того как он выделится синим квадратом отпускаем мышку.

После этого появится список доступных полей класса, в котором выбираем созданный нами mtkView:

И в месте откуда мы начинали тянуть связь появится следующее:

Больше к визуальному редактору возвращаться не будем и вкладку с Main.storyboard можно закрыть.
Очищаем окно и класс рендера
Закончим настройку MTKView, вернемся к файлу ViewController.swift. Для рисования надо создать device, назначить его view, а так же задать формат пикселей и цвет очистки фона. Для этого в метод viewDidLoad после super.viewDidLoad() добавляем код. Device создается с помощью функции MTLCreateSystemDefaultDevice и назначается полю device, формат пикселей в поле colorPixelFormat в виде значения .bgra8Unorm. Цвет очистки по умолчанию черный, можно не трогать, но давайте сделаем фон зеленый, это поле clearColor и цвет для нее с помощью вызова функции MTLClearColorMake со значениями 0, 1, 0, 1 (красный, зеленый, синий и альфа). Здесь же можно настроить частоту кадров, зададим 60 полю preferredFramesPerSecond.
| Swift | 1
2
3
4
5
| let device = MTLCreateSystemDefaultDevice()!
mtkView.device = device
mtkView.colorPixelFormat = .bgra8Unorm
mtkView.clearColor = MTLClearColorMake(0, 1, 0, 1)
mtkView.preferredFramesPerSecond = 60 |
|
Теперь создадим класс рендера. Для этого в левой части окна, где все файлы проекта кликнем по папке с именем проекта (с желтым значком) и выберем New File, в открывшемся окне тип Swift, потом введем имя файла Renderer. В итоге в файлах проекта должен появится файл на том же уровне, что и остальные файлы исходного кода, с именем Renderer.swift. В нем будет всего одна строка import Foundation, добавим к ней импорт import MetalKit. И создадим класс с таким же именем как и у файла, наследуемся от класса NSObject и реализуем протокол MTKViewDelegate. Этот протокол объявляет методы, которые MTKView будет вызывать для выполнения рендеринга и уведомления вас об изменении его размера. Этот протокол требует реализации двух методов draw и mtkView. Первый метод занимается перерисовкой окна, а второй вызывается при изменении его размера. С помощью Xcode можно создать реализацию этих методов в пару кликов, просто кликнув Fix на ошибке. В итоге получим следующую заготовку кода.
| Swift | 1
2
3
4
5
6
7
8
9
10
| import Foundation
import MetalKit
class Renderer: NSObject, MTKViewDelegate {
func draw(in view: MTKView) {
}
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
}
} |
|
Для рисования в классе рендера нам потребуются device и view, поэтому создадим для них поля класса и конструктор принимающих их как параметры. В конце не забудем вызвать конструктор родительского класса.
| Swift | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| import Foundation
import MetalKit
class Renderer: NSObject, MTKViewDelegate {
let device: MTLDevice
let mtkView: MTKView
init(view: MTKView, device: MTLDevice) {
mtkView = view
self.device = device
super.init()
}
func draw(in view: MTKView) {
}
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
}
} |
|
Теперь вернемся к классу ViewController. Создадим в нем объект класса Renderer и поле хранящее его, а так же назначим его делегатом для MTKView. Это последние изменения для класса ViewController, вот его окончательный вид:
| Swift | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| import Cocoa
import MetalKit
class ViewController: NSViewController {
@IBOutlet var mtkView: MTKView!
var renderer: Renderer!
override func viewDidLoad() {
super.viewDidLoad()
let device = MTLCreateSystemDefaultDevice()!
mtkView.device = device
mtkView.colorPixelFormat = .bgra8Unorm
mtkView.clearColor = MTLClearColorMake(0, 1, 0, 1)
mtkView.preferredFramesPerSecond = 60
renderer = Renderer(view: mtkView, device: device)
mtkView.delegate = renderer
}
} |
|
Теперь займемся очисткой экрана. Начнем с создания очереди команд, управляющую доступом к графическому процессору. Создадим поле let commandQueue: MTLCommandQueue в классе для ее хранения и создадим в конструкторе используя метод makeCommandQueue у объекта device.
Очередь команд хранит последовательность буферов команд, в которые мы будем создавать и записывать команды графического процессора. Команды состоят из таких вещей, как операции установки состояния (которые описывают, как объекты должны быть отрисованы и с помощью каких ресурсов они должны быть отрисованы), а также вызовы отрисовки, которые сообщают графическому процессору о необходимости отрисовки геометрии, используя шейдеры и создавая пиксели отображаемые на экране.
В нашем приложении мы будем генерировать один командный буфер на кадр. Когда наш MTKView попросит отрисовать кадр, мы используем следующий код для создания буфера команд, в который мы будем кодировать наши команды отрисовки:
| Swift | 1
2
3
4
| func draw(in view: MTKView) {
let commandBuffer = commandQueue.makeCommandBuffer()!
//...
} |
|
Так же нам потребуются несколько объектов из нашего view: дескриптор прохода рендеринга (renderPassDescriptor) и текущий объект рисования (drawable). Когда у нас есть эти объекты, мы можем создать кодировщик команд рендеринга (commandEncoder), являющийся объектом, который фактически записывает команды в буфер команд.
| Swift | 1
2
3
4
5
6
7
8
| func draw(in view: MTKView) {
let commandBuffer = commandQueue.makeCommandBuffer()!
if let renderPassDescriptor = view.currentRenderPassDescriptor,
let drawable = view.currentDrawable,
let commandEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor) {
//..
}
} |
|
Для простой очистки окна надо просто завершить кадр.
| Swift | 1
2
3
| commandEncoder.endEncoding()
commandBuffer.present(drawable)
commandBuffer.commit() |
|
В результате запустив программу мы получим окно зеленого цвета.

Файл Renderer.swift
| Swift | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| import Foundation
import MetalKit
class Renderer: NSObject, MTKViewDelegate {
let device: MTLDevice
let mtkView: MTKView
let commandQueue: MTLCommandQueue
init(view: MTKView, device: MTLDevice) {
mtkView = view
self.device = device
commandQueue = device.makeCommandQueue()!
super.init()
}
func draw(in view: MTKView) {
}
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
let commandBuffer = commandQueue.makeCommandBuffer()!
if let renderPassDescriptor = view.currentRenderPassDescriptor,
let drawable = view.currentDrawable,
let commandEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor) {
commandEncoder.endEncoding()
commandBuffer.present(drawable)
commandBuffer.commit()
}
}
} |
|
Вершинные данные и Vertex Descriptor
Для рисования треугольника нам потребуются данные вершин и их описание. Не будем заморачиваться с разными координатами и нарисуем все в пространстве экрана, по горизонтали ось Х от -1.0 до 1.0, по вертикали ось Y от -1.0 до 1.0. Создадим свойство класса для буфера вершин var buffer: MTLBuffer! и их описания var vertexDescriptor: MTLVertexDescriptor!. Вершины будут в формате [x y z]. Создадим функцию loadResources в которой будем все делать. Буфер для вершин создается при помощи метода makeBuffer у девайса, в который надо передать сами вершины и их размер в байтах.
| Swift | 1
2
| let vertices: [Float32] = [-0.5, -0.5, 0.0, 0.5, -0.5, 0.0, 0.0, 0.5, 0.0]
buffer = device.makeBuffer(bytes: vertices, length: vertices.count * MemoryLayout<Float32>.size, options: [])! |
|
Теперь создадим описание вершин, то есть Vertex Descriptor. Vertex Descriptor это метаданные, которые предоставляют имя, положение и формат (тип данных) для каждого атрибута вершин, составляющих данные нашей модели. Формат у нас float3, оффсет равен 0, индекс буфера 0, а stride равен размеру данных на одну вершину в байтах. В результате функция loadResources примет следующий вид:
| Swift | 1
2
3
4
5
6
7
8
9
| func loadResources() {
let vertices: [Float32] = [-0.5, -0.5, 0.0, 0.5, -0.5, 0.0, 0.0, 0.5, 0.0]
buffer = device.makeBuffer(bytes: vertices, length: vertices.count * MemoryLayout<Float32>.size, options: [])!
vertexDescriptor = MTLVertexDescriptor()
vertexDescriptor.attributes[0].format = .float3
vertexDescriptor.attributes[0].offset = 0
vertexDescriptor.attributes[0].bufferIndex = 0
vertexDescriptor.layouts[0].stride = 3 * MemoryLayout<Float32>.size
} |
|
И не забудем в конце конструктора вызывать нашу новую функцию
| Swift | 1
2
3
4
5
6
7
8
| class Renderer: NSObject, MTKViewDelegate {
//...
init(view: MTKView, device: MTLDevice) {
//...
loadResources()
}
//...
} |
|
Шейдеры
Шейдер — это небольшая программа, работающая на графическом процессоре, и в нашем примере проекта нам придется иметь дело с двумя видами шейдеров: вершинными и фрагментными. Вершинный шейдер запускается один раз для каждой вершины, каждый раз, когда мы рисуем геометрию. Задача вершинного шейдера заключается в преобразовании данных вершин из координатного пространства, в котором они были смоделированы, в координатное пространство, ожидаемое остальной частью конвейера рендеринга металла, которое называется пространством отсечения.
Добавим к проекту файл Metal с именем Shaders.metal. Этот файл будет содержать все написанные нами функции шейдера. Этот файл будет написан не на Swift, а на варианте языка программирования C++ (основанном на С++14), называемом Metal Shading Language (спецификация языка).
Оба шейдера будут в этом файле. В начале подключим заголовок и неймспейс.
| C++ | 1
2
| #include <metal_stdlib>
using namespace metal; |
|
Далее определим структуру с входными данными вершинного шейдера.
| C++ | 1
2
3
| struct VertexIn {
float3 position [[attribute(0)]];
}; |
|
У нас всего одно поле с типом float3 и именем position, пометка [[attribute(0)]] означает что данные используются описанные в нашем Vertex Descriptor под индексом 0.
Теперь определим вершинную функцию
| C++ | 1
2
3
| vertex float4 vertex_main(VertexIn vertexIn [[stage_in]]) {
return float4(vertexIn.position, 1.0f);
} |
|
Функция имеет имя vertex_main (имя может быть любым, но уникальным), возвращает тип float4, помечена ключевым словом vertex, а среди параметров наша структура с пометкой [[stage_in]] (данные поступают из конвеера). В теле просто возвращаем позицию вершины из нашей структуры дополнив w координатой.
Теперь определим фрагментную функцию. По аналогии с вершинной у нее имя fragment_main, возвращаемый тип float4, используется ключевое слово fragment и нет входных параметров (в нашем примере не нужны). Возвращаем просто красный цвет в формате [r, g, b, a]
| C++ | 1
2
3
| fragment float4 fragment_main() {
return float4(1.0f, 0.0f, 0.0f, 1.0f);
} |
|
В результате файл Shaders.metal имеет следующий вид
| C++ | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| #include <metal_stdlib>
using namespace metal;
struct VertexIn {
float3 position [[attribute(0)]];
};
vertex float4 vertex_main(VertexIn vertexIn [[stage_in]]) {
return float4(vertexIn.position, 1.0f);
}
fragment float4 fragment_main() {
return float4(1.0f, 0.0f, 0.0f, 1.0f);
} |
|
Загрузка шейдеров и Render Pipeline Descriptor
Вернемся к файлу Renderer.swift. Создадим функцию buildPipeline в которой загрузим наши шейдеры и создадим описание Render Pipeline.
Для загрузки шейдеров надо создать библиотеку вызовом всего метода у девайса под названием makeDefaultLibrary
| Swift | 1
2
3
| guard let library = device.makeDefaultLibrary() else {
fatalError("Could not load default library from main bundle")
} |
|
Теперь создадим в классе поле для Render Pipeline State var renderPipeline: MTLRenderPipelineState!. А в нашей функции создадим объект типа | Objective-C | 1
| MTLRenderPipelineDescriptor |
|
и заполним у него нужные нам параметры
| Objective-C | 1
2
3
4
5
| let pipelineDescriptor = MTLRenderPipelineDescriptor()
pipelineDescriptor.vertexFunction = library.makeFunction(name: "vertex_main")
pipelineDescriptor.fragmentFunction = library.makeFunction(name: "fragment_main")
pipelineDescriptor.colorAttachments[0].pixelFormat = mtkView.colorPixelFormat
pipelineDescriptor.vertexDescriptor = vertexDescriptor |
|
Создадим шейдерные функции из библиотеки с помощью метода makeFunction передав в него название функции. Укажем формат пикселей взяв его из mtkView, а так же созданный ранее Vertex Descriptor. И завершим все вызовом метода makeRenderPipelineState у девайса, который на основе наших данных создаст нужный нам объект. Не забудем вызывать ее в конце конструктора. В итоге наша функция примет следующий вид:
| Swift | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| func buildPipeline() {
guard let library = device.makeDefaultLibrary() else {
fatalError("Could not load default library from main bundle")
}
let pipelineDescriptor = MTLRenderPipelineDescriptor()
pipelineDescriptor.vertexFunction = library.makeFunction(name: "vertex_main")
pipelineDescriptor.fragmentFunction = library.makeFunction(name: "fragment_main")
pipelineDescriptor.colorAttachments[0].pixelFormat = mtkView.colorPixelFormat
pipelineDescriptor.vertexDescriptor = vertexDescriptor
do {
renderPipeline = try device.makeRenderPipelineState(descriptor: pipelineDescriptor)
} catch {
fatalError("Could not create render pipeline state object: \(error)")
}
} |
|
Привет, треугольник
Мы всего в 1 шаге от нашего первого треугольника. Осталось выполнить всего 3 простых действия в функции draw (до вызова endEncoding). Надо установить наши ранее созданные Render Pipeline State и Vertex Buffer, а так же вызвать метод drawPrimitives у commandEncoder как показано ниже:
| Swift | 1
2
3
| commandEncoder.setRenderPipelineState(renderPipeline)
commandEncoder.setVertexBuffer(buffer, offset: 0, index: 0)
commandEncoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3) |
|
И после запуска программы появится красный треугольник на зеленом фоне.

Полный код класса рендера будет выглядеть следующим образом:
| Swift | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
| import Foundation
import MetalKit
class Renderer: NSObject, MTKViewDelegate {
let device: MTLDevice
let mtkView: MTKView
let commandQueue: MTLCommandQueue
var buffer: MTLBuffer!
var vertexDescriptor: MTLVertexDescriptor!
var renderPipeline: MTLRenderPipelineState!
init(view: MTKView, device: MTLDevice) {
mtkView = view
self.device = device
commandQueue = device.makeCommandQueue()!
super.init()
loadResources()
buildPipeline()
}
func loadResources() {
let vertices: [Float32] = [-0.5, -0.5, 0.0, 0.5, -0.5, 0.0, 0.0, 0.5, 0.0]
buffer = device.makeBuffer(bytes: vertices, length: vertices.count * MemoryLayout<Float32>.size, options: [])!
vertexDescriptor = MTLVertexDescriptor()
vertexDescriptor.attributes[0].format = .float3
vertexDescriptor.attributes[0].offset = 0
vertexDescriptor.attributes[0].bufferIndex = 0
vertexDescriptor.layouts[0].stride = 3 * MemoryLayout<Float32>.size
}
func buildPipeline() {
guard let library = device.makeDefaultLibrary() else {
fatalError("Could not load default library from main bundle")
}
let pipelineDescriptor = MTLRenderPipelineDescriptor()
pipelineDescriptor.vertexFunction = library.makeFunction(name: "vertex_main")
pipelineDescriptor.fragmentFunction = library.makeFunction(name: "fragment_main")
pipelineDescriptor.colorAttachments[0].pixelFormat = mtkView.colorPixelFormat
pipelineDescriptor.vertexDescriptor = vertexDescriptor
do {
renderPipeline = try device.makeRenderPipelineState(descriptor: pipelineDescriptor)
} catch {
fatalError("Could not create render pipeline state object: \(error)")
}
}
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
}
func draw(in view: MTKView) {
let commandBuffer = commandQueue.makeCommandBuffer()!
if let renderPassDescriptor = view.currentRenderPassDescriptor,
let drawable = view.currentDrawable,
let commandEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor) {
commandEncoder.setRenderPipelineState(renderPipeline)
commandEncoder.setVertexBuffer(buffer, offset: 0, index: 0)
commandEncoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
commandEncoder.endEncoding()
commandBuffer.present(drawable)
commandBuffer.commit()
}
}
} |
|
В конце прикреплю архивы с XCode (hello-triangle.zip) проектом и бинарный App (hello-triangle.app.zip).
|