Подробный гайд: Создание графической C++-библиотеки для macOS с Metal и Objective-C++ - Часть 2
6. Создание окна с Metal-слоем
// src/platform_mac.mm (продолжение)
// Делегат окна — нужен для обработки нажатия на "крестик"
@interface MyWindowDelegate : NSObject <NSWindowDelegate>
@property (nonatomic, assign) bool shouldClose;
@end
@implementation MyWindowDelegate
- (BOOL)windowShouldClose:(NSWindow*)sender {
self.shouldClose = true;
return NO; // Не закрываем автоматически — пусть библиотека решает
}
@end
struct WindowData {
NSWindow* window;
CAMetalLayer* metalLayer;
MyWindowDelegate* delegate;
bool shouldClose;
};
void* platform_createWindow(int width, int height, const char* title, bool resizable) {
@autoreleasepool {
NSRect frame = NSMakeRect(0, 0, width, height);
NSWindowStyleMask style = NSWindowStyleMaskTitled
| NSWindowStyleMaskClosable
| NSWindowStyleMaskMiniaturizable;
if (resizable) style |= NSWindowStyleMaskResizable;
NSWindow* window = [[NSWindow alloc]
initWithContentRect:frame
styleMask:style
backing:NSBackingStoreBuffered
defer:NO];
[window setTitle:[NSString stringWithUTF8String:title ?: ""]];
[window center];
[window makeKeyAndOrderFront:nil];
// ---- Metal layer ----
CAMetalLayer* layer = [CAMetalLayer layer];
layer.device = MTLCreateSystemDefaultDevice();
layer.pixelFormat = MTLPixelFormatBGRA8Unorm;
layer.framebufferOnly = YES;
layer.contentsScale = [window backingScaleFactor];
window.contentView.wantsLayer = YES;
window.contentView.layer = layer;
// ---- Делегат ----
MyWindowDelegate* delegate = [[MyWindowDelegate alloc] init];
delegate.shouldClose = false;
window.delegate = delegate;
// Сохраняем в heap (caller ответственен за освобождение)
WindowData* data = new WindowData{window, layer, delegate, false};
// ARC: удерживаем ObjC-объекты вручную через __bridge_retained
data->window = (__bridge_retained NSWindow*)window;
data->metalLayer = (__bridge_retained CAMetalLayer*)layer;
data->delegate = (__bridge_retained MyWindowDelegate*)delegate;
return data;
}
}
bool platform_windowShouldClose(void* handle) {
WindowData* data = static_cast<WindowData*>(handle);
return data->delegate.shouldClose;
}
void platform_destroyWindow(void* handle) {
WindowData* data = static_cast<WindowData*>(handle);
// Освобождаем __bridge_retained объекты
CFRelease((__bridge CFTypeRef)data->delegate);
CFRelease((__bridge CFTypeRef)data->metalLayer);
CFRelease((__bridge CFTypeRef)data->window);
delete data;
}
Важно про ARC:
Если вы компилируете с -fobjc-arc (рекомендуется), то __bridge_retained передаёт владение C++-коду, а CFRelease возвращает его. Без ARC используйте обычный retain/release.
7. Metal-рендерер
// src/metal_renderer.mm
#import <Metal/Metal.h>
#import <QuartzCore/CAMetalLayer.h>
struct MetalRendererData {
id<MTLDevice> device;
id<MTLCommandQueue> commandQueue;
CAMetalLayer* layer;
};
void* platform_createMetalRenderer(void* windowHandle) {
WindowData* wdata = static_cast<WindowData*>(windowHandle);
auto* rdata = new MetalRendererData();
rdata->device = wdata->metalLayer.device;
rdata->layer = wdata->metalLayer;
rdata->commandQueue = [rdata->device newCommandQueue];
return rdata;
}
struct FrameData {
id<CAMetalDrawable> drawable;
id<MTLCommandBuffer> commandBuffer;
MTLRenderPassDescriptor* passDesc;
};
FrameData platform_beginFrame(void* rendererHandle) {
auto* rdata = static_cast<MetalRendererData*>(rendererHandle);
FrameData f{};
// Получаем drawable из Metal layer (блокирует до vsync при необходимости)
f.drawable = [rdata->layer nextDrawable];
if (!f.drawable) return f;
f.commandBuffer = [rdata->commandQueue commandBuffer];
f.passDesc = [MTLRenderPassDescriptor renderPassDescriptor];
f.passDesc.colorAttachments[0].texture = f.drawable.texture;
f.passDesc.colorAttachments[0].loadAction = MTLLoadActionClear;
f.passDesc.colorAttachments[0].storeAction = MTLStoreActionStore;
f.passDesc.colorAttachments[0].clearColor = MTLClearColorMake(0.1, 0.2, 0.3, 1.0);
return f;
}
void platform_endFrame(void* rendererHandle, FrameData frame) {
if (!frame.drawable) return;
[frame.commandBuffer presentDrawable:frame.drawable];
[frame.commandBuffer commit];
}
8. Связующий слой (src/mylib.cpp)
#include "mylib.h"
// Forward declarations C-функций из platform_mac.mm
extern "C" {
bool platform_init();
void platform_terminate();
void platform_pollEvents();
void platform_waitEvents();
void platform_waitEventsTimeout(double);
void* platform_createWindow(int, int, const char*, bool);
bool platform_windowShouldClose(void*);
void platform_destroyWindow(void*);
void* platform_createMetalRenderer(void*);
// ...
}
namespace mylib {
bool init() { return platform_init(); }
void terminate() { platform_terminate(); }
void pollEvents() { platform_pollEvents(); }
void waitEvents() { platform_waitEvents(); }
void waitEventsTimeout(double t) { platform_waitEventsTimeout(t); }
struct Window::Impl {
void* handle = nullptr;
};
Window* Window::create(const WindowConfig& cfg) {
auto* w = new Window();
w->impl_ = new Impl();
w->impl_->handle = platform_createWindow(cfg.width, cfg.height, cfg.title, cfg.resizable);
return w;
}
void Window::destroy() {
if (impl_) {
platform_destroyWindow(impl_->handle);
delete impl_;
impl_ = nullptr;
}
delete this;
}
bool Window::shouldClose() const {
return platform_windowShouldClose(impl_->handle);
}
} // namespace mylib
Альтернатива extern "C":
Можете держать функции в namespace mylib::detail и объявлять их в platform.h, включаемом только в .cpp/.mm файлах. Так безопаснее.
9. Сборка через CMake
cmake_minimum_required(VERSION 3.20)
project(mylib LANGUAGES CXX OBJCXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# Включаем ARC для Objective-C++ файлов
set(CMAKE_OBJCXX_FLAGS "${CMAKE_OBJCXX_FLAGS} -fobjc-arc")
add_library(mylib STATIC
src/mylib.cpp
src/platform_mac.mm
src/metal_renderer.mm
)
target_include_directories(mylib
PUBLIC include
PRIVATE src
)
# macOS frameworks
target_link_libraries(mylib PUBLIC
"-framework Cocoa"
"-framework Metal"
"-framework QuartzCore"
"-framework Foundation"
)
# Пример
add_executable(triangle examples/triangle.cpp)
target_link_libraries(triangle PRIVATE mylib)
Сборка:
mkdir build && cd build
cmake .. -DCMAKE_BUILD_TYPE=Release
cmake --build . -j
./triangle
10. Нюансы, о которых часто забывают
A. Высокий DPI (Retina)
Размер окна в точках ≠ размер в пикселях.
Для Metal важно:
// При ресайзе окна:
layer.drawableSize = CGSizeMake(
window.contentView.bounds.size.width * window.backingScaleFactor,
window.contentView.bounds.size.height * window.backingScaleFactor
);
B. Обработка ресайза
Добавьте в делегат:
- (void)windowDidResize:(NSNotification*)notification {
NSWindow* w = notification.object;
NSSize size = w.contentView.bounds.size;
CGFloat scale = w.backingScaleFactor;
[(CAMetalLayer*)w.contentView.layer setDrawableSize:CGSizeMake(size.width*scale, size.height*scale)];
}
C. Почему без [NSApp run] окно может "не отрисоваться"
Частая ошибка: забыть [NSApp updateWindows] в pollEvents. Без этого AppKit не сбрасывает dirty-регионы, и окно остаётся белым/серым.
D. Cmd+Q и меню
Без главного меню горячие клавиши macOS не работают. Я добавил минимальное меню в platform_init() — этого достаточно.
E. Курсор, ввод, события мыши
Все эти события приходят через pollEvents().
Чтобы их обрабатывать, вам нужен callback-механизм:
mylib::setKeyCallback([](int key, int action) { ... });
Реализация:
Внутри sendEvent: AppKit вызывает методы NSResponder вашего окна — переопределяете keyDown:, mouseDown: и вызываете пользовательский callback.
F. Info.plist и app bundle
Для полноценного приложения (с иконкой, правильным именем в меню) нужен .app bundle. Для разработки и тестов достаточно просто исполняемого файла — [NSApp setActivationPolicy:NSApplicationActivationPolicyRegular] делает его видимым.
11. Итоговая схема потока выполнения
main thread
│
├─► mylib::init()
│ └─► [NSApplication sharedApplication]
│ └─► [NSApp setActivationPolicy:...]
│ └─► [NSApp finishLaunching] ← НЕ [NSApp run]!
│
├─► Window::create()
│ └─► NSWindow + CAMetalLayer setup
│
└─► while (!shouldClose) {
mylib::pollEvents(); ──► nextEventMatchingMask (distantPast)
beginFrame(); ──► [layer nextDrawable]
... rendering ...
endFrame(); ──► [cmdBuf presentDrawable:] + commit
}
Поток никогда не блокируется надолго — только на nextDrawable (ждёт vsync) и, опционально, на waitEvents (если пользователь сам этого хочет).
Важно
| Проблема | Решение |
|---|---|
[NSApp run] блокирует поток |
Заменить на [NSApp finishLaunching] + ручной pollEvents() |
| Нельзя перенести в фоновый поток | И не надо — вся UI-часть живёт в main thread, но не блокирует |
| Программа сразу завершается | Без event loop AppKit "не знает", что приложение живо — наш pollEvents это чинит |
| Библиотека захватывает управление | Пользователь сам пишет while(!closed) и вызывает pollEvents() |
Эта архитектура — та же, что используют GLFW, SDL2/3, sokol_app и bgfx. Она проверена годами и позволяет безболезненно добавить потом бэкенды для Windows (Win32 + D3D12/Vulkan) и Linux (X11/Wayland).
Мы делимся этой технической информацией, чтобы помочь вам в решении задач — используйте её с пониманием. Статья носит рекомендательный характер, поэтому, пожалуйста, применяйте описанные методы осмотрительно.