Исторически так сложилось, что macOS сильно отличается от других OS, когда дело доходит до нативной работы с окнами и графикой. И нельзя сказать, что это определенно плохо или хорошо. В этом плане Apple решили пойти своей любимой дорогой: "мы лучше знаем что тебе нужно, поэтому сделали все за тебя". Как же это проявляется?

Окна и потоки

Хоть в названии статьи и написано про OpenGL и JVM, важно объяснить из-за чего весь сыр-бор.

Главная проблема в том, как работает оконная система в macOS. Если взглянуть на Windows и Linux, то можно проследить такую логику: мы создаем окно, и в "бесконечном" цикле ожидаем сообщения с разнообразными событиями, приходящих в текущий поток. Это дает нам гибкость в управлении этими самыми событиями, а также контроль за тем, в каком потоке исполняется наш код.

В macOS же у нас все вынесено на более высокий уровень. Мы не слушаем события в цикле, а просто создаем объект, и добавляем к нему слушатели конкретных событий. Если это нам ещё не приносит дискомфорта, то вот настоящая проблема - окно обязательно должно создаваться в главном потоке нашего процесса. Если попытаться пойти против правил, то приложение просто крашнется.

Получается, что macOS, при работе с окнами, умеет оперировать только с главным потоком, и где-то под капотом оно получает события именно в него. В чем же проблема?

JVM

Если вы когда-нибудь выводили список всех текущих потоков процесса, либо читали крэш-лог, то могли видеть, что помимо главного потока, есть ещё несколько других. К примеру, несколько потоков может использоваться для сборщика мусора.

И ладно если бы в фоне просто висело несколько потоков - нам бы это не усложнило жизнь. Проблема начинает проявляться тогда, когда мы попытаемся создать нативное окно на macOS в main методе. Окажется, что мы и не в главном потоке на самом деле находимся. Он называется main, но таковым не является. А вот чтобы JVM запустила нашу программу в главном потоке, нужно добавить параметр -XstartOnFirstThread. Возможно, кто-то встречался с этим параметром, когда пытался запустить Minecraft, либо любую игру на OpenGL.

OpenGL

Самой популярной библиотекой для работы с OpenGL в Java является LWJGL. И если зайти на их официальный сайт, то во вкладке с примером можно увидеть примечание для macOS как раз с тем параметром, который я описал выше. Это не удивительно, ведь в этом примере используется GLFW, который сам по себе немного глупый и неповоротливый. Давайте разберемся, так ли нам необходим этот параметр?

OpenGL без окна

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

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

А как же обстоят дела на macOS? GLFW нам предлагает создать невидимое окно... Хорошо, но мы уже выяснили, что не можем использовать такой вариант, когда находимся не в главном потоке. Здесь можно похвалить Apple - хоть OpenGL в их системе и идет с отставанием, и уже даже помечен как deprecated, но все-равно может иногда показать себя с более лучшей стороны, чем у конкурентов. Оказывается, у нас есть уникальная возможность создать контекст без окна. Для этого существует целый пласт функций, называемый CGL (CoreGL).

Вот пример его создания на C++:

GLint num;
CGLPixelFormatObj format;
CGLPixelFormatAttribute attributes[4] = {
        kCGLPFAAccelerated,
        kCGLPFAOpenGLProfile,
        kCGLOGLPVersion_GL3_Core,
        (CGLPixelFormatAttribute) 0
};
CGLChoosePixelFormat(attributes, &format, &num);

CGLContextObj context;
CGLCreateContext(format, nullptr, &context);

Обычные NS окна под капотом используют именно этот механизм. Что самое замечательно - мы не привязаны к главному потоку, и можем создавать контекст тогда, когда нам это удобно.

OpenGL с окном

Но вот незадача - нам нужно именно окно. Если пойти в гугл с таким вопросом, то особо ничего не узнать. Все-таки придется остановиться на параметре -XstartOnFirstThread?

Но как, к примеру, действует AWT и JavaFX, когда создают свои окна? Ведь при их использовании этот параметр не нужен. Тут нам понадобится "магия Apple". Оказывается, есть способ запустить код в главном потоке из любого места программы. И делается это с помощью функции performSelectorOnMainThread. В следующем примере я покажу, как можно использовать его в утилитарном классе с привязкой к JNI для запуска из JVM.

Вот так будет выглядеть наш утилитарный класс на Kotlin:

package com.huskerdev.test

class MacOSUtils {
    companion object {
        @JvmStatic external fun invokeOnMainThread(runnable: Runnable)
    }
}

А так JNI код на Objective-C в одном файле:

#import <Cocoa/Cocoa.h>

#include <jni.h>

/* ======================
       ThreadUtilities
   ====================== */

@interface ThreadUtilities : NSObject { }
+ (void)performOnMainThread:(BOOL)wait block:(void (^)())block;
@end

@implementation ThreadUtilities
static NSArray<NSString*> *javaModes = [[NSArray alloc] initWithObjects:
        NSDefaultRunLoopMode, NSModalPanelRunLoopMode, NSEventTrackingRunLoopMode, @"grapl", nil];

+ (void)invokeBlock:(void (^)())block {
    block();
}

+ (void)invokeBlockCopy:(void (^)(void))blockCopy {
    blockCopy();
    Block_release(blockCopy);
}

+ (void)performOnMainThread:(BOOL)wait block:(void (^)())block {
    if (![NSThread isMainThread]){
        [self
            performSelectorOnMainThread:    wait == YES ? @selector(invokeBlock:) : @selector(invokeBlockCopy:)
            withObject:                     wait == YES ? block : Block_copy(block)
            waitUntilDone:                  wait
            modes:                          javaModes
        ];
    } else
        block();
}
@end

/* ======================
            JNI
   ====================== */

extern "C" JNIEXPORT void 
JNICALL Java_com_huskerdev_test_MacOSUtils_invokeOnMainThread(JNIEnv* env, jobject, jobject runnable) {
    JavaVM* jvm;
    env->GetJavaVM(&jvm);

    jobject runnableGlobal = env->NewGlobalRef(runnable);
    jclass runnableClass = env->GetObjectClass(runnableGlobal);
    jmethodID runMethod = env->GetMethodID(runnableClass, "run", "()V");

    [ThreadUtilities performOnMainThread:YES block:^() {
        JNIEnv* env;
        jvm->AttachCurrentThread((void**)&env, NULL);
        env->CallVoidMethod(runnableGlobal, runMethod);
        env->DeleteGlobalRef(runnableGlobal);
        jvm->DetachCurrentThread();
    }];
}

С помощью такого страшного кода мы сможем запрашивать выполнение действий в главном потоке с помощью вызова MacOSUtils.invokeOnMainThread { ... }. В этом коде также есть переключатель sync/async, но я не стал его выносить отдельным параметром, чтобы все немного упростить.

Я проверял код из примера с официального сайта LWJGL с вызовом нашей функции - он прекрасно заработал без каких-либо параметров JVM.

Итог

OpenGL на macOS хоть и имеет некоторые ограничения, связанные с потоками, но всех их можно обойти, если сильно того захотеть.

Я буду рад, если кто-нибудь укажет на недостатки, или поделится своим опытом работы с LWJGL и GLFW под macOS.

Комментарии (2)


  1. Javian
    08.04.2024 05:54

    off Vulkan в macOS еще не завезли?


    1. Huskers Автор
      08.04.2024 05:54

      Насколько я помню, Vulkan (и MoltenVK) сам по себе изначально offscreen, поэтому думаю уже давно завезли