Подробный гайд: Создание графической C++-библиотеки для macOS с Metal и Objective-C++ - Часть 2

Гайд по созданию графической C++-библиотеки для macOS с Metal и Objective-C++: решение проблемы блокировки потока NSApplication через ручную обработку событий.

2026.10.05                  


Подробный гайд: Создание графической C++-библиотеки для macOS с Metal и Objective-C++ - Часть 2Подробный гайд: Создание графической 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).


Мы делимся этой технической информацией, чтобы помочь вам в решении задач — используйте её с пониманием. Статья носит рекомендательный характер, поэтому, пожалуйста, применяйте описанные методы осмотрительно.


Статью подготовил: Аверко Денис Сергеевич @Nymexis г. Омск (специалист по ЗИ)

Комментарии

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