凹みTips

C++、JavaScript、Unity、ガジェット等の Tips について雑多に書いています。

Unity でテクスチャに使う画像をネイティブ側で読み込んでみた(低レベルネイティブプラグインインターフェース編)

はじめに

Unity で画像をネイティブ側で非同期に読み込みたい、というお話が Twitter で出ていたので、やってみました。

従来の問題点

StreamingAssets ディレクトリやウェブから WWWUnityWebRequest で持ってきた画像ファイルを使う場合、Unity にデコードを任せると以下のようなコードになります。

using UnityEngine;
using UnityEngine.Profiling;
using System.Collections;

public class LoadTextureSync : MonoBehaviour
{
    [SerializeField]
    string path = "hecomi.png";

    IEnumerator Start()
    {
        var material = GetComponent<Renderer>().material;

        var url = System.IO.Path.Combine(Application.streamingAssetsPath, path);
#if UNITY_EDITOR
        url = "file://" + url;
#endif

        using (var www = new WWW(url)) 
        {
            yield return www;
            Profiler.BeginSample("Decode Texture (Sync)");
            {
                material.mainTexture = www.texture;
            }
            Profiler.EndSample();
        }
    }
}

ここで、例えば 400x400 の PNG 画像を読んでみます。

f:id:hecomi:20180622203201p:plain

このタイミングでのプロファイラを見てみると次のようになっています。

f:id:hecomi:20180622203537p:plain

WWW.texture では内部的にデコードとテクスチャの生成・更新が走ると思われるため、読み込みにメインスレッドで数 ms かかっています。しかしながらこの API はメインスレッドからしか呼ぶことができません。結果として、そこそこ大きめのテクスチャを複数を読み込む場合はフレームレートの維持が難しくなります。

非同期読み込みの方針

概要

そこでこのデコード処理およびテクスチャの更新部分をスレッドから実行することでこの処理をメインスレッドから追い出します。まず、デコード処理に関しては次のようなものが考えられます:

  • libjpeg や libpng、freeimage といったライブラリを使ってネイティブ側でデコードする DLL を作成
  • OpenCV for Unity を使って C# 側でデコード(デコード処理自体はネイティブ)

これらの処理を await Task.Run() すれば非同期でデコードができます(ネイティブ側でスレッドを立てても○)。今回はライセンス的に楽な前者にします。また、説明が簡単になるように libpng を利用して PNG だけを対象の画像フォーマットとします。

テクスチャの更新に関しては次のものが考えられます。

  • 低レベルネイティブプラグインインターフェースを利用して各プラットフォームごとに更新
  • CusomTextureUpdate を利用してプラットフォーム非依存で更新

前者は以下の記事で解説したのと同じ方法を取ります。

tips.hecomi.com

後者は 2017.2 から追加された機能で CommandBuffer.IssuePluginCustomTextureUpdate() を利用して呼ばれたネイティブ側のコールバック内で該当のテクスチャのバッファを渡す(デコードした RGBA などの配列の先頭ポインタを渡す)ことで更新します。

unity3d.com

こちらはメリット・デメリットあるので両方解説しますが、本エントリでは低レベルネイティブプラグインインターフェースでの方法を説明します。CusomTextureUpdate に関しては次の記事で説明します。

やること

先に目指す処理の大まかな流れをコードで示します。

// 非同期でロードするネイティブ側の機能
var loader = CreateLoader();

// データのダウンロード
var www = new WWW(url);
yield return www;

// PNG の生データ
byte[] data = www.data;

// PNG のデコード(非同期)
var handle = GCHandle.Alloc(data, GCHandleType.Pinned);
var pointer = handle.AddrOfPinnedObject();
await Task.Run(() => 
{
    loader.Load(pointer, data.Length);
});
handle.Free();

// テクスチャ生成(画像サイズは loader から取得)
var width = loader.GetWidth();
var height = lodaer.GetHeight();
var texture = new Texture2D(width, height, TextureFormat.RGBA32, false);

// テクスチャをレンダラに設定
var renderer = GetComponent<Renderer>();
renderer.material.mainTexture = texture;

// テクスチャのポインタ(OpenGL なら GLuint のハンドル)をセット
loader.SetTexture(texture.GetNativeTexturePtr());

// レンダリングスレッドでテクスチャを更新するよう指示
yield return new WaitForEndOfFrame();
GL.IssuePluginEvent(GetRenderEventFunc(), 0);

デコード処理を Task.Run() で実行し、更新は低レベルネイティブプラグインインターフェースを使ってレンダリングスレッドで行う形です。

環境

すべての環境の解説をすると冗長になってしまうので(...言い換えると面倒なので...)、今回は Mac(Metal でなく OpenGL)および AndroidOpenGL ES 2)を対象とします。

サンプルコード

github.com

プロジェクト構成

各プラットフォームで同じソースコードを参照できるようにしておくと便利だと思います。私は以下のようなディレクトリ構成にしてみました。

Your Unity Project
├── Assets
│   ├── Plugins
│   │   ├── Android
│   │   │  └── libNativeTextureLoader.so
│   │   ├── x86_64
│   │       └── libNativeTextureLoader.bundle
│   ├── ...
│   :
│
├── Plugins
│   └── NativeTextureLoader
│       ├── src
│       │   ├── *.h
│       │   └── *.cpp
│       ├── jni
│       │   ├── Android.mk
│       │   ├── Application.mk
│       │   └── libpng-android (git submodule)
│       └── mac
│            └── NativeTextureLoader.xcodeproj
 :

コードは src に入れておき、各プロジェクトを jnimac に入れておく方法です。winios も必要であれば追加できます。詳しくは以下をご参照下さい:

github.com

実装解説

冒頭で述べたように、今回は libpng に限定します。プロジェクトに合わせて適切なフォーマット毎の構成に改変してください。

libpng の導入(mac

今回は libpng を使います。Mac の場合は brew で入れて、Xcode で読み込むよう Link Binary と Search Paths を設定しておきます。

$ brew install libpng

f:id:hecomi:20180624131310p:plain

f:id:hecomi:20180624131314p:plain

出力ディレクトリは Unity の該当のディレクトリにしておきます。

f:id:hecomi:20180624130941p:plain

また、src 以下のファイルは適宜含めるようにしましょう。

libpng の導入(Android

Android 向けには以下の libpng-android を利用します。

github.com

これを jni 以下に配置し、Android.mk は次のようにして取り込むようにしておきます。

TOP_LOCAL_PATH := $(call my-dir)

include $(CLEAR_VARS)

include $(TOP_LOCAL_PATH)/libpng-android/jni/Android.mk
include $(CLEAR_VARS)

LOCAL_PATH := $(TOP_LOCAL_PATH)

NDK_APP_DST_DIR := ../../../Assets/NativeTextureLoader/Plugins/Android
LOCAL_MODULE    := libNativeTextureLoader
LOCAL_C_INCLUDES := $(LOCAL_PATH)/libpng-android/jni/.
LOCAL_SRC_FILES  := $(wildcard ../src/*.cpp)
LOCAL_CFLAGS     := -std=c++14
LOCAL_LDLIBS     := -llog -landroid -lGLESv1_CM -lGLESv2
LOCAL_STATIC_LIBRARIES := libpng

include $(BUILD_SHARED_LIBRARY)

プラグイン側コード

コードは次のようになります。要望があれば追記しますが、とりあえず libpng の詳しい解説は省略します。Unity 側から呼ばれる順番としては、

  1. Load() でダウンロードしてきた PNG の生データが渡されデコード(Task.Run() スレッド)
  2. GetWidth()GetHeight() でテクスチャの大きさを取得して Unity 側でテクスチャ生成(メインスレッド)
  3. GLuint でテクスチャのハンドルを SetTexture() してもらう(メインスレッド)
  4. UpdateTexture() でデコードした画像を GPU 側にセット(レンダリングスレッド)

になります。

png_loader.h
#include <memory>
#include <png.h>
#ifdef __APPLE__
#include <OpenGL/gl.h>
#else
#include <GLES/gl.h>
#include <GLES2/gl2.h>
#endif

namespace NativeImageLoader
{

class PngLoader
{
public:
    void Load(const void *pData, size_t size);
    void SetTexture(GLuint texture) { m_texture = texture; }
    void UpdateTexture();
    bool HasLoaded() const { return m_hasLoaded; }
    int GetWidth() const { return m_width; }
    int GetHeight() const { return m_height; }

private:
    std::unique_ptr<unsigned char[]> m_data;
    bool m_hasLoaded = false;
    GLuint m_texture = 0;
    GLenum m_format = 0;
    GLint m_alignment = 1;
    size_t m_dataSize = 0;
    png_uint_32 m_width = 0;
    png_uint_32 m_height = 0;
};

}
png_loader.cpp
#include <png.h>
#include "png_loader.h"

using namespace NativeImageLoader;


void PngLoader::Load(const void *pData, size_t dataSize)
{
    if (dataSize < 8) return;

    const auto *pHeader = reinterpret_cast<const png_byte*>(pData);
    if (png_sig_cmp(pHeader, 0, 8)) return;

    auto png = png_create_read_struct(
        PNG_LIBPNG_VER_STRING,
        nullptr,
        nullptr,
        nullptr);
    if (!png) return;

    auto info = png_create_info_struct(png);
    if (!info) return;

    struct Data
    {
        const unsigned char *m_pData;
        unsigned long m_offset;
    };
    Data data
    {
        static_cast<const unsigned char*>(pData),
        8,
    };

    png_set_read_fn(
        png,
        &data,
        [](png_structp png, png_bytep buf, png_size_t size)
        {
            auto &data = *static_cast<Data*>(png_get_io_ptr(png));
            memcpy(buf, data.m_pData + data.m_offset, size);
            data.m_offset += size;
        });

    png_set_sig_bytes(png, 8);
    png_read_png(
        png,
        info,
        PNG_TRANSFORM_STRIP_16 | PNG_TRANSFORM_PACKING | PNG_TRANSFORM_EXPAND,
        nullptr);

    const auto type = png_get_color_type(png, info);
    switch (type)
    {
        case PNG_COLOR_TYPE_PALETTE:
            png_set_palette_to_rgb(png);
            m_format = GL_RGB;
            m_alignment = 1;
            break;
        case PNG_COLOR_TYPE_RGB:
            m_format = GL_RGB;
            m_alignment = 1;
            break;
        case PNG_COLOR_TYPE_RGBA:
            m_format = GL_RGBA;
            m_alignment = 4;
            break;
        default:
            return;
    }

    const size_t rowBytes = png_get_rowbytes(png, info);
    m_width = png_get_image_width(png, info);
    m_height = png_get_image_height(png, info);
    m_data = std::make_unique<unsigned char[]>(rowBytes * m_height);

    const auto rows = png_get_rows(png, info);
    for (int i = 0; i < m_height; ++i)
    {
        const size_t offset = rowBytes * i;
        memcpy(m_data.get() + offset, rows[i], rowBytes);
    }

    m_hasLoaded = true;

    png_destroy_read_struct(&png, &info, nullptr);
}

void PngLoader::UpdateTexture()
{
    if (!HasLoaded() || m_texture == 0) return;

    glBindTexture(GL_TEXTURE_2D, m_texture);
    glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    glPixelStorei(GL_UNPACK_ALIGNMENT, m_alignment);
    glTexSubImage2D(
        GL_TEXTURE_2D,
        0,
        0,
        0,
        m_width,
        m_height,
        m_format,
        GL_UNSIGNED_BYTE,
        m_data.get());

    glBindTexture(GL_TEXTURE_2D, 0);
}

これらの関数を DLL 経由で使えるように以下のようにします。

main.cpp
#include <queue>
#include "png_loader.h"

using namespace NativeImageLoader;
using UnityRenderEvent = void(*)(int);

std::queue<PngLoader*> g_updateQueue;


extern "C"
{

void * CreateLoader()
{
    return new PngLoader();
}

void DestroyLoader(PngLoader *pPngLoader)
{
    if (pPngLoader == nullptr) return;
    delete pPngLoader;
}

void Load(PngLoader *pPngLoader, const void *pData, size_t dataSize)
{
    if (pPngLoader == nullptr || pData == nullptr) return;
    pPngLoader->Load(pData, dataSize);
}

void SetTexture(PngLoader *pPngLoader, GLuint texture)
{
    if (pPngLoader == nullptr) return;
    pPngLoader->SetTexture(texture);
    g_updateQueue.push(pPngLoader);
}

int GetWidth(PngLoader *pPngLoader)
{
    if (pPngLoader == nullptr) return 0;
    return pPngLoader->GetWidth();
}

int GetHeight(PngLoader *pPngLoader)
{
    if (pPngLoader == nullptr) return 0;
    return pPngLoader->GetHeight();
}

void OnRenderEvent(int eventId)
{
    while (!g_updateQueue.empty())
    {
        g_updateQueue.front()->UpdateTexture();
        g_updateQueue.pop();
    }
}

UnityRenderEvent GetRenderEventFunc()
{
    return OnRenderEvent;
}

}

ビルド

各プラットフォーム向けにビルドします。サンプルでは次のような簡単なビルドスクリプトを用意してみました。

build.sh
#!/bin/sh

PluginName='NativeTextureLoader'

cd `dirname $0`

# Mac
echo "\n==============================\n Mac\n==============================\n"
rm -rf ../../Assets/$PluginName/Plugins/x86_64/$PluginName.bundle*
xcodebuild -project mac/$PluginName.xcodeproj -configuration Release build

# Android
echo "\n==============================\n Android\n==============================\n"
rm -rf ../../Assets/$PluginName/Plugins/Android/lib$PluginName.so
ndk-build

ビルドが成功すれば Unity のプロジェクトの Plugins ディレクトリ以下にビルドしたプラグインが配置されます。

Unity 側コード

こうして出力したプラグインを次のように Unity から利用します。

Lib.cs
using System;
using System.Runtime.InteropServices;

public static class Lib
{
    const string pluginName = "NativeTextureLoader";

    [DllImport(pluginName)]
    public static extern IntPtr CreateLoader();

    [DllImport(pluginName)]
    public static extern void DestroyLoader(IntPtr loader);

    [DllImport(pluginName)]
    public static extern void Load(IntPtr loader, IntPtr data, int size);

    [DllImport(pluginName)]
    public static extern void SetTexture(IntPtr loader, IntPtr texture);

    [DllImport(pluginName)]
    public static extern void UpdateTexture(IntPtr loader);

    [DllImport(pluginName)]
    public static extern void UpdateTextureImmediate(IntPtr loader);

    [DllImport(pluginName)]
    public static extern int GetWidth(IntPtr loader);

    [DllImport(pluginName)]
    public static extern int GetHeight(IntPtr loader);

    [DllImport(pluginName)]
    public static extern IntPtr GetRenderEventFunc();
}

これらのネイティブ側で用意された関数を利用して、冒頭のコードのように非同期のロード処理を次のように行います。

LoadTextureAsync.cs
using UnityEngine;
using UnityEngine.Networking;
using System.Collections;
using System.Runtime.InteropServices;
using System.Threading.Tasks;

public class LoadTextureAsync : MonoBehaviour
{
    [SerializeField]
    string path = "hecomi.png";

    System.IntPtr loader_;

    void Start()
    {
        StartCoroutine(LoadImage());
    }

    void OnDestroy()
    {
        if (loader_ != System.IntPtr.Zero)
        {
            Lib.DestroyLoader(loader_);
        }
    }

    IEnumerator LoadImage()
    {
        var url = System.IO.Path.Combine(Application.streamingAssetsPath, path);
#if UNITY_EDITOR
        url = "file://" + url;
#endif

        var req = UnityWebRequest.Get(url);
        yield return req.SendWebRequest();

        if (req.isDone)
        {
            OnDataLoaded(req.downloadHandler.data);
        }
        else
        {
            Debug.LogError(req.error);
        }
    }

    async void OnDataLoaded(byte[] data)
    {
        var handle = GCHandle.Alloc(data, GCHandleType.Pinned);
        var pointer = handle.AddrOfPinnedObject();
        await Task.Run(() => 
        {
            loader_ = Lib.CreateLoader();
            Lib.Load(loader_, pointer, data.Length);
        });
        handle.Free();

        var width = Lib.GetWidth(loader_);
        var height = Lib.GetHeight(loader_);
        var texture = new Texture2D(width, height, TextureFormat.RGBA32, false);
        Lib.SetTexture(loader_, texture.GetNativeTexturePtr());

        var renderer = GetComponent<Renderer>();
        renderer.material.mainTexture = texture;

        StartCoroutine(IssuePluginEvent());
    }

    IEnumerator IssuePluginEvent()
    {
        yield return new WaitForEndOfFrame();
        GL.IssuePluginEvent(Lib.GetRenderEventFunc(), 0);
    }
}

これでデコード処理とテクスチャの更新処理をメインスレッドから追い出すことができました。

問題点

本サンプルのままだと、LoadTextureAsync コンポーネントの数だけコルーチンが周り、GL.IssuePluginEvent() が発行されてしまい非効率です。また、走らせるタスクの数も多いためその分のオーバーヘッドもあります。なので、マネージャを作成し、IssuePluginEvent() は 1 回のみにしたり、1 つのタスクで複数のデコード処理を行ったりなどの最適化が必要です。また、予め画像のサイズが分かっているのであれば最初にテクスチャを生成して使い回せばその分のコストも削減できます。各自プロジェクトに合わせて適切な最適化を行ってください。

f:id:hecomi:20180624180533p:plain

あと、本コードのままだと横着しているので PNG 画像が反転しています。プラグイン側でひっくり返すかシェーダでひっくり返すかの処理が必要です。。

おわりに

今回は低レベルネイティブプラグインインターフェースを使ったテクスチャの非同期読み込みを解説しました。今回は OpenGL に限定したコードを書きましたが、これが metal や DirectX も絡んでくると面倒です。そこで、Unity 2018.2 から CustomTextureUpdate という仕組みが導入され、テクスチャの更新をグラフィクス API 非依存に行うことが可能になりました。次の記事ではこちらの解説を行いたいと思います。

追記(2018/07/16)

次の記事を書きました:

tips.hecomi.com

謝辞

@TyounanMOTI さんにテクスチャ更新時のプルリクを頂きました、ありがとうございます!