🧰

Jetson NanoでD言語 + EGL + OpenGL ES2で画面描画

2022/12/31に公開

家にNVIDIA Jetson Nano 2GBが転がっており、ろくに活用できていなかったので、D言語で3Dモデルを回転させてみることにしました。

ふつうにXWindow等のGUIアプリケーションを動かすには非力すぎるので、今回はXWindowやWayland等なし、DRMとEGLとOpenGL ES2で画面描画をやってみました。

D言語でJetson Nanoの低レベルAPIを叩いて3D描画するというサンプルになっているかと思います。C/C++やRustでやりたい人にも参考になるかもですね。

これを発展させると、Jetson Nanoをそのままゲーム機にすることもできそうですね。

今回のサンプルコードはこちらにあります。

https://github.com/outlandkarasu-sandbox/dman-egl

なぜD言語か?

  • D言語だから。
  • C/C++のソフトウェア資産が柔軟かつ簡単に利用でき、Jetson Nanoのライブラリの活用も容易。
  • コンパイルが速く、Jetson Nano上でもビルド等が行えて、トライ&エラーがしやすい。
  • プロトタイピング用途で雑に書いても許されるゆるふわ言語。
  • でも最低限のところは多彩な言語機能で色々制約できる。
    • スコープガード(scope(exit))とか
    • アサーション(enforce)とか

D言語の準備

Jetson NanoのCPUはARM Cortex-A57なので、LLVMをバックエンドとするLDCを利用します。

D言語の公式実装DMDは現状x86/64以外は未対応です。

D言語のJetson Nanoへのインストール

インストールスクリプトを使うことで簡単にインストールできます。

# 各ツールインストール。もう入っていたら不要。
$ sudo apt install gnupg curl unzip xz-utils

# インストールスクリプトのダウンロード
$ curl https://dlang.org/install.sh | bash -s

# インストール実行
$ ~/dlang/install.sh install ldc
Downloading and unpacking https://github.com/ldc-developers/ldc/releases/download/vX.X.X/ldc2-X.X.X-linux-aarch64.tar.xz
################################################################################################################# 100.0%
Using dub X.XX.X shipped with ldc-X.XX.X

Run `source ~/dlang/ldc-X.XX.X/activate` in your shell to use ldc-X.XX.X.
This will setup PATH, LIBRARY_PATH, LD_LIBRARY_PATH, DMD, DC, and PS1.
Run `deactivate` later on to restore your environment.

# インストールされたバージョンのactivateをsourceして有効化
$ source ~/dlang/ldc-X.XX.X/activate

# 以降、ldc2 でコンパイラが利用できる。
(ldc-X.XX.X)$ ldc2 --version

私の別の記事も参考になるかもしれません。

D言語プロジェクトの作成

D言語にはdubというパッケージマネージャ兼ビルドツールが同梱されています。dubによりプロジェクトの作成・ビルド・実行が行えます。

# hellodプロジェクト作成
(ldc-X.XX.X)$ dub init hellod
Package recipe format (sdl/json) [json]:
# 以下色々聞かれる。とりあえずEnterで問題なし。
# ...

(ldc-X.XX.X)$ cd hellod

# プロジェクトのディレクトリでdub runすると、ビルド&実行される。
(ldc-X.XX.X)$ dub run
Performing "debug" build using /home/ikemen/dlang/ldc-X.XX.X/bin/ldc2 for aarch64.
hellod ~master: building configuration "application"...
Linking...
Running hellod
Edit source/app.d to start your project. #これが実行結果

今回の記事のプロジェクト

今回のサンプルコードはこちらにあります。(再掲)

https://github.com/outlandkarasu-sandbox/dman-egl

git cloneして、Jetson Nanoで先述の手順でdub runするとちゃんと動くと思います。多分。

Jetson NanoのGUI無効化(Headless化)

さて、今回私が使用したのは、NVIDIA Jetsonシリーズの中でも最弱のJetson Nano、しかもその中でもさらに最弱のNVIDIA Jetson Nano 2GBです。つまりメモリが2GBしかない!

それでも、動画エンコーダー・デコーダーもGPUもちゃんと積まれている立派なJetsonです。
とはいえメモリが貧弱なのは否めないので、デスクトップ環境などは起動しないようにします。こうすることで600MB程度メモリが空きます。

$ sudo systemctl set-default multi-user.target

元に戻したいときはこちらを実行します。

$ sudo systemctl set-default graphical.target

XWindow等なしで画面描画するには

ようやく本題の画面描画です。

さて、デスクトップ環境等が起動しなくなってメモリに余裕ができましたが、肝心の画面描画が一般的なGUIツールキットでできなくなりました。(当たり前)
それでも、C/C++まで視野に入れれば色々ライブラリを使った方法があるかと思います。(Jetson Nanoで使えるかは未知)

今回は、せっかくのHeadless Jetson Nanoつまり(だいぶ余裕はありますが)組込Linuxです。
できるだけOSに近いレベルでAPIを叩いて画面描画をやってやりましょう!
というわけで、ほぼLinuxとJetsonが提供しているライブラリだけを利用する以下の構成でいきます。

  • 画面のフレームバッファ等管理にLinuxのDRM
  • OpenGL ESコンテキスト準備にEGL
  • 3D描画にOpenGL ES2

DRMについては、Jetson(というかTegra)のAPIとしてリファレンスがNVIDIAから提供されています。微妙に本家DRMと違うところがあるので注意が必要です。(デバイスファイル名がdrm-nvdc固定な点など)

XWindowなしでの描画の流れ

サンプルコードでは、以下の流れで画面描画を行います。

  1. EGLの動的ロード・初期化
  2. EGLのデバイス情報・使用ディスプレイ取得
  3. DRMで画面解像度を取得・設定
  4. EGLストリーム・出力先レイヤー等設定
  5. EGLサーフェイス設定
  6. EGLコンテキスト設定・選択
  7. OpenGL ES2で描画

このうち、7. OpenGL ES2で描画 は昔書いた記事とほぼ同様なので、詳細は割愛します。

https://qiita.com/outlandkarasu@github/items/f55252bd8e39da14c9e0

こちらではfbx形式のファイルからassimpというライブラリ(すごい名前だ……)でモデルデータを読み込んだり、SDL2という低レイヤー担当のライブラリを使っています。
assimpは残念ながらD言語のポーティングライブラリが微妙、SDL2はHeadlessのJetsonではうまく動かないので今回使っていません……。
モデルデータ・テクスチャデータは、今回、D言語のコードとして埋め込むという力業で解決しました。
他の部分についてはほぼ上記記事の流用です。

というわけで、OpenGL ES2を使うところまで見ていきましょう。

EGLのロード

今回、bindbc-glesというバインディングライブラリを見つけたので、こちらを利用してみました。
……が、必要なライブラリが足りなかったり(そもそもEGL extensionsは何もない)したため、forkして機能追加などを行っています。有用そうなものはそのうち本家にPull Requestでも投げようかと思います。

とりあえずEGLとGLESの関数のロードです。

    // EGLロード
    immutable eglSupport = loadEGL();
    switch (eglSupport)
    {
    case EGLSupport.noLibrary:
    case EGLSupport.badLibrary:
    case EGLSupport.noContext:
        writefln("load error: %s", eglSupport);
        return;
    default:
        break;
    }
    scope (exit)
        unloadEGL();
    writefln("loaded: %s", eglSupport);

    // GLESロード
    immutable glesSupport = loadGLES();
    switch (glesSupport)
    {
    case GLESSupport.noLibrary:
    // case GLESSupport.badLibrary: bindbc-glesで何故かOpenGL ESじゃない関数を読み込もうとしていたりするので無視
    case GLESSupport.noContext:
        writefln("load error: %s", glesSupport);
        return;
    default:
        break;
    }
    scope (exit)
        unloadGLES();
    writefln("loaded: %s", glesSupport);

bindbcの使い方に沿って共有ライブラリ(libEGL.solibGLESv2.soなど)から関数のロードを行います。bindbcは例外機構のサポートなどがないbetterC環境でも利用できるよう考えられているため、上記のようにやや泥臭い感じになります。

ところで、bindbc-glesはなぜかOpenGL ESじゃない関数もロードしようとして失敗します。ロードできた関数は問題なく使用できるので、エラーは無視して進めるようにしています。

EGL拡張関数のロード

続いて、EGLの拡張機能の関数をロードしています。

    // 使用関数ロード
    static immutable procs = [
        "eglQueryDevicesEXT",
        "eglGetPlatformDisplayEXT",
        "eglQueryDeviceStringEXT",
        "eglQueryDeviceAttribEXT",
        "eglGetOutputLayersEXT",
        "eglQueryOutputLayerStringEXT",
        "eglQueryOutputLayerAttribEXT",
        "eglCreateStreamKHR",
        "eglDestroyStreamKHR",
        "eglStreamConsumerOutputEXT",
        "eglCreateStreamProducerSurfaceKHR",
        "eglOutputLayerAttribEXT"
    ];
    static foreach (proc; procs)
    {
        enforce(loadEGLExtProcAddress!proc);
    }

これらの関数は、なぜか通常のダイナミックローディングが行えず、eglGetProcAddressを介してしか取得できませんでした。

色々面倒だったので、以下のユーティリティ関数をforkしたbindbc-glesに追加して使用しています。

/// procNameで指定した関数をeglGetProcAddressで取得する。
bool loadEGLExtProcAddress(string procName)()
{
    alias ProcAddressType = mixin("p" ~ procName);
    alias procAddressVariable = mixin(procName);
    auto procAddress = cast(ProcAddressType) eglGetProcAddress(procName.ptr);
    if (!procAddress) return false;
    procAddressVariable = procAddress;
    return true;
}

EGLデバイス情報取得・DRMディスクリプタオープン

EGLの拡張機能を利用して、EGLで使われるデバイスと対応するディスプレイを取得できます。
さらにDRMディスクリプタのファイル名も取得できるので、それを元にDRMディスクリプタをオープンします。

    // デバイス情報取得
    EGLint deviceCount;
    enforce(eglQueryDevicesEXT(0, null, &deviceCount) && deviceCount > 0);
    auto devices = new EGLDeviceEXT[](deviceCount);
    enforce(eglQueryDevicesEXT(cast(EGLint) devices.length, &devices[0], &deviceCount) && deviceCount > 0);
    auto device = devices[0];

    // ディスプレイ取得
    auto display = eglGetPlatformDisplayEXT(EGL_PLATFORM_DEVICE_EXT, device, null);
    enforce(display != EGL_NO_DISPLAY);
    enforce(eglInitialize(display, null, null));
    enforce(eglBindAPI(EGL_OPENGL_ES_API));

    // デバイスと対応するDRMファイル取得
    auto drmFileName = enforce(eglQueryDeviceStringEXT(device, EGL_DRM_DEVICE_FILE_EXT));
    enforce(drmFileName.fromStringz == "drm-nvdc");

    // DRMディスクリプタオープン
    auto drmFD = drmOpen(drmFileName, null);
    errnoEnforce(drmFD >= 0);
    scope (exit)
        drmFD.drmClose();

DRMで画面解像度を取得・設定

DRMについては以下のサイトなどを参考にさせて頂きました。色々情報が少なすぎる……。

他に、NVIDIAのルートファイルシステムに含まれるgraphics_demosは非常に参考にしました。

DRMケーパビリティ設定

まず、DRMの全プレーン(メインの描画用・カーソル表示用・オーバーレイ用など)を参照可能にするためにケーパビリティを設定します。

    // DRMケーパビリティ設定
    errnoEnforce(drmFD.drmSetClientCap(DRM_CLIENT_CAP_ATOMIC, 1) == 0);
    errnoEnforce(drmFD.drmSetClientCap(DRM_CLIENT_CAP_UNIVERSAL_PLANES, 1) == 0);

DRMリソース等取得

次に、画面解像度を調べるためにDRMのリソース・コネクター・エンコーダーを順に取得していきます。

    // DRMリソース取得
    auto drmResources = drmFD.drmModeGetResources();
    errnoEnforce(drmResources);
    scope (exit)
        drmResources.drmModeFreeResources();

    // 最初のコネクター取得
    enforce(drmResources.count_connectors > 0, "connector not found");
    auto drmConnector = errnoEnforce(drmFD.drmModeGetConnector(drmResources.connectors[0]));
    scope (exit)
        drmConnector.drmModeFreeConnector();

    // 接続確認。HDMIポートにディスプレイが繋がっていないとエラー!
    enforce(drmConnector.connection == drmModeConnection.DRM_MODE_CONNECTED, "unconnected");

    // エンコーダー取得
    enforce(drmConnector.encoder_id != 0, "no valid encoder");
    auto drmEncoder = errnoEnforce(drmFD.drmModeGetEncoder(drmConnector.encoder_id));
    scope (exit)
        drmEncoder.drmModeFreeEncoder();

    // モード取得
    enforce(drmConnector.count_modes > 0, "no valid mode");

    // モード情報表示
    writefln("mode: %s (%d x %d) vrefresh: %d",
        drmConnector.modes[0].name.fromStringz,
        drmConnector.modes[0].hdisplay,
        drmConnector.modes[0].vdisplay,
        drmConnector.modes[0].vrefresh);
    auto height = drmConnector.modes[0].vdisplay;
    auto width = drmConnector.modes[0].hdisplay;
    auto fps = drmConnector.modes[0].vrefresh;

DRMモードのCRTCへの設定

CRTCはたぶんモニター(CRT)を指す概念だと思われます。そちらにモード・プレーンを設定していきます。

    // エンコーダーと対応するCRTCにモード設定
    auto drmCRTC = drmEncoder.crtc_id;
    errnoEnforce(drmFD.drmModeSetCrtc(
            drmCRTC,
            -1,
            0,
            0,
            &drmConnector.connector_id,
            1,
            null) >= 0);

    // プレーン設定
    errnoEnforce(drmFD.drmModeSetPlane(
            drmPlane,
            drmCRTC,
            -1,
            0,
            0,
            0,
            width,
            height,
            0,
            0,
            width << 16,
            height << 16,
    ) == 0);

非常にめんどくさかったですが、上記まででDRMの画面解像度等の設定は完了です……。

EGLストリーム・出力先レイヤー等設定

次に、画面に描画済みバッファを渡していくためのEGLストリーム等の設定を行います。

    // EGLストリーム生成
    EGLint[] streamAttributes = [EGL_NONE];
    auto eglStream = eglCreateStreamKHR(display, &streamAttributes[0]);
    enforce(eglStream != EGL_NO_STREAM_KHR);
    scope (exit)
        eglDestroyStreamKHR(display, eglStream);

    // EGL出力レイヤー取得
    EGLint layerCount;
    EGLOutputLayerEXT layer;
    enforce(eglGetOutputLayersEXT(display, null, null, 0, &layerCount) && layerCount > 0);
    enforce(eglGetOutputLayersEXT(display, null, &layer, 1, &layerCount) && layerCount > 0);

    // EGLストリーム出力先設定
    enforce(eglStreamConsumerOutputEXT(display, eglStream, layer));

    // スワップ間隔設定
    enforce(eglOutputLayerAttribEXT(display, layer, EGL_SWAP_INTERVAL_EXT, 1));

ディスプレイに対してeglCreateStreamKHRでEGLストリームを生成し、そこをEGLストリームのコンシューマーにしていることが分かりますね。
あと、画像のスワップ間隔も一応明示的に設定しています。

EGLサーフェイス設定

EGLの描画対象を表すサーフェイスの設定を行います。

    // 設定生成
    EGLint[] configAttributes = [
        EGL_SURFACE_TYPE, EGL_STREAM_BIT_KHR,
        EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT,
        EGL_RED_SIZE, 1,
        EGL_GREEN_SIZE, 1,
        EGL_BLUE_SIZE, 1,
        EGL_DEPTH_SIZE, 8,
        EGL_SAMPLES, 0,
        EGL_NONE,
    ];
    EGLint configCount;
    enforce(eglChooseConfig(display, &configAttributes[0], null, 0, &configCount)
            && configCount > 0);
    auto configList = new EGLConfig[](configCount);
    enforce(eglChooseConfig(display, &configAttributes[0], &configList[0], configCount, &configCount)
            && configCount > 0);
    auto config = configList[0];

    // EGL producerサーフェース生成
    EGLint[] surfaceAttributes = [
        EGL_WIDTH, width,
        EGL_HEIGHT, height,
        EGL_NONE,
    ];
    auto surface = eglCreateStreamProducerSurfaceKHR(display, config, eglStream, &surfaceAttributes[0]);
    enforce(surface != EGL_NO_SURFACE);
    scope (exit)
        eglDestroySurface(display, surface);

EGL_RED_SIZE等でピクセルの色深度を指定しています。1にすると勝手に最低限に拡張してくれるようです。
設定の流れとして、まず希望するアトリビュートを列挙してeglChooseConfigを呼び、そこで利用可能とされた一覧を以降で利用するようになっていますね。

EGLコンテキスト設定・選択

次はOpenGL ES2のためのEGLコンテキストを初期化します。

    // EGLコンテキスト生成
    EGLint[] contextAttributes = [
        EGL_CONTEXT_CLIENT_VERSION, 2,
        EGL_NONE,
    ];
    auto context = enforce(eglCreateContext(display, config, null, &contextAttributes[0]));
    scope (exit)
        eglDestroyContext(display, context);

    // 現在のコンテキスト選択
    enforce(eglMakeCurrent(display, surface, surface, context));
    scope (exit)
        eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);

    // サーフェイスサイズ取得
    EGLint surfaceWidth;
    EGLint surfaceHeight;
    enforce(eglQuerySurface(display, surface, EGL_WIDTH, &surfaceWidth));
    enforce(eglQuerySurface(display, surface, EGL_HEIGHT, &surfaceHeight));
    writefln("surface: %dx%d", surfaceWidth, surfaceHeight);

eglCreateContextでOpenGL ESのバージョンを指定してコンテキスト生成、その後eglMakeCurrentで現在のコンテキストとして設定しています。

ここまで来れば、あとはOpenGL ESの関数が利用できます!

    // ビューポート最大サイズ取得
    GLint[] maxViewportDims = [-1, -1];
    glGetIntegerv(GL_MAX_VIEWPORT_DIMS, &maxViewportDims[0]);
    writefln("max viewport dims: %s", maxViewportDims);

    // ビューポートの設定
    glViewport(0, 0, width, height);
    glEnable(GL_DEPTH_TEST);
    
    // 等々...

まとめ

DRMやEGLやOpenGL ES2関数といった普段あまり使わない関数の調査・ポーティングが非常に大変でした……。
今回DRMなどは自力でポーティングしておきました。このあたりで何かやろうとしている人の一助になれば幸いです。

Discussion