11
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Unity】ネイティブフォントでの絵文字表示

11
Last updated at Posted at 2025-12-17

はじめに

本記事はAdvent Calendar 2025の17日目の記事です。

Unityでの絵文字の表示

Unityでゲームを作る時、ゲーム内であらかじめ決まった文字だけではなく、プレイヤーが自由入力できるようなテキストを表示したい時ってありますよね!
自由に設定できるプロフィールや、プレイヤー同士がコミュニケーションを取れるアプリ内チャットなどが当てはまると思います。
しかし、OS標準のテキストエンジンだったら自由に絵文字なども表示できるのに、Unityで実装すると絵文字が全然表示できない、という問題があることに気づくと思います。

この投稿では、このような問題を解決すべく、UnityでOS標準の絵文字を表示できるようになるまでを書いていこうと思います。

下調べ

まずは、Unityで絵文字を描画するために、関連するアプローチなどを調べました。

Unity純正の機能で描画する

Unityでは、TextMesh ProやUI ToolkitのLabelで文字を表示することができます。
比較的最近のUnityバージョン(少なくともUnity 6)では、絵文字で使うような色付きフォントも描画できるようになりました。

詳しくは、Unityの公式ドキュメントを見てみてください。

TextMesh Pro
https://docs.unity3d.com/Packages/com.unity.textmeshpro@3.2/manual/ColorEmojis.html

UI ToolKit
https://docs.unity3d.com/6000.3/Documentation/Manual/UIE-color-emojis.html

え、じゃあこれで終わりじゃん!

と思うかもしれませんが、この機能、制約が多すぎて「絵文字が描画できている」とはとても言えません。次のような問題があります。

  • OS標準のFontを探して、それをTextMesh Proのフォントとして動的に登録することになるが、OSのバージョンによって、内包しているフォントをうまく扱えないケースがある。特にiOS
    • 上のドキュメントにも書いてある「Dynamic OS FontAsset has limited support on some iOS devices.」
  • ZWJを使った絵文字など、複雑なコードポイントの絵文字を描画できない
    • 例:👨‍❤️‍👨

2点目の問題に関しては、さまざまな黒魔術を駆使することで一応対処できそうではあります。TextEngineのInternalなAPIに対してリフレクションを使った呼び出し、もしくはC#用のフォント関係のライブラリを使い、描画できないGlyphを、UnicodeのPrivate Use Areaにある単一コードポイントの文字に割り当て、MonoBehaviourなどで実際に描画したい文字をこの文字に置き換えると言う方法などが考えられます。

しかし、1点目の方法に関してはUnityの文字描画の仕組みを使う以上は回避が難しいです。

そこで別のアプローチを考えましょう。

Sprite Assetを使った方法

UnityのTextMesh ProやUI Toolkitでは、テキストの中にSpriteを埋め込むことができます。例えばゲーム中のUIで、文字は普通のフォントで描画し、数字部分だけリッチな画像にする、と言うようなことできるようになっています。
この機能を使うことで、絵文字を描画することができます。次の記事のケースではまさにこのアプローチで絵文字を描画しています。
https://qiita.com/K0uhe1D/items/93ca4a397a16543f6f4c

この手法で取り扱う絵文字の画像は、フリーのフォントから生成されることが多く、OS標準の絵文字を扱っているケースは見つかりません。
基本的に、テキスト内で使われる想定のSprite画像は、Unity Editor上にインポートして生成することが普通です。そのため、各OSの絵文字を表示するためには、それぞれのOSのフォントファイルをあらかじめUnity Editorに持ってくる必要があり、ライセンス上の問題が発生するフォントがあります。

しかし、絵文字の描画に使うSprite Assetをランタイムで作成できればこの問題は解決できます。
今回はこのアプローチで、絵文字を描画していこうと思います。

実装内容

方針が決まったので、実装に進んでいきます。今回は、TextMesh Proで描画するところまでやります。

絵文字を描画するには、ネイティブのAPIを呼び出す必要があるので、Unityとネイティブで、それぞれ実装が必要です。

  • Unityで実装する部分
    • Sprite Assetの作成に使うAtlasについて管理・ネイティブと連携する機能
    • TMPで絵文字を描画するためのヘルパーとなるコンポーネント
  • ネイティブ拡張として実装する部分
    • 絵文字の一覧を元に、それらをすべて描画した一枚のアトラスPNGを作成する機能

Unity部分

Unity部分を実装していきます。
その前に、全体の処理の流れを一旦整理しましょう。

  1. ストレージにアトラス画像のキャッシュがあるかを確認する。ある場合は3へ
  2. アトラス画像がない場合、ネイティブに描画したい絵文字のリストを渡し、アトラスを作成してもらう
  3. アトラス画像を元に、Sprite Assetを動的に作成する
    この際に、各SpriteをUnicodeのPUAの文字に割り当て、本当のコードポイントとPUAコードポイントの変換テーブルを作成しておく
  4. 描画する際には、絵文字を変換テーブルに従って置き換える。すると、絵文字は自動でSpriteを使って描画される

コードは、いくつか抜粋して以下に示します。

ネイティブにアトラス画像を作ってもらう部分

全ての絵文字を描画してもらう必要があるので、まずはそのリストを用意しておく必要があります。今回は、絵文字のテストに使われる以下のファイルを扱うことにします。
https://www.unicode.org/Public/emoji/latest/emoji-test.txt

このファイルをローカルに保存しておき、ネイティブ側でこれをパースしてアトラス画像を作れるようにします。

Unity側から見たネイティブの処理は、次のような形のメソッドになっていると良さそうです

private static extern void CreateEmojiAtlas(string inputPath, string outputDir);

inputPathにはテストファイルのパスを、outputDirには出力されるアトラスのディレクトリを渡します。

Sprite Assetを動的に作成する部分・変換テーブルを作成する部分

private void Create()
{
    _spriteAsset = ScriptableObject.CreateInstance<TMP_SpriteAsset>();
    _spriteAsset.name = "EmojiSpriteAsset";
    var shader = Shader.Find("TextMeshPro/Sprite");
    var material = new Material(shader);
    _dynamicGeneratedObjects.Add(material);
    _spriteAsset.material = material;

    var texture = new Texture2D(2, 2);
    _dynamicGeneratedObjects.Add(texture);
    var imageBytes = File.ReadAllBytes(_pngFilePath);
    texture.LoadImage(imageBytes, true);
    _spriteAsset.spriteSheet = texture;
    material.mainTexture = texture;
    _spriteAsset.spriteInfoList = new List<TMP_Sprite>();

    var jsonString = File.ReadAllText(_jsonFilePath);
    var frames = JsonUtility.FromJson<Frames>(jsonString);
    var index = 0;

    foreach (var frame in frames.frames)
    {
        // UIKitの座標系では、左上が(0,0)だが、Unityでは、左下が(0,0)なので変換しておく必要がある
        var unityY = texture.height - frame.frame.y - frame.frame.h;
        var sprite = Sprite.Create(
            texture,
            new Rect(frame.frame.x, unityY, frame.frame.w, frame.frame.h),
            new Vector2(0.5f, 0.5f),
            100f
        );
        var spriteInfo = new TMP_Sprite
        {
            name = frame.name,
            unicode = 0,
            pivot = new Vector2(0.5f, 0.5f),
            xAdvance = 0,
            scale = 1f,
            sprite = sprite,
        };
        var glyph = new TMP_SpriteGlyph
        {
            index = (uint)index,
            sprite = sprite,
            scale = 1f,
            glyphRect = new GlyphRect(
                frame.frame.x,
                unityY,
                frame.frame.w,
                frame.frame.h
            ),
            metrics = new GlyphMetrics(
                frame.frame.h,
                frame.frame.w,
                0,
                frame.frame.h,
                frame.frame.w
            )
        };

        _spriteAsset.spriteGlyphTable.Add(glyph);

        // Unicodeは、U+F0000〜U+FFFFDのPrivate Use Areaを使う
        var privateCodePoint = 0xf0000 + index;
        _spriteAsset.spriteCharacterTable.Add(
            new TMP_SpriteCharacter((uint)privateCodePoint, _spriteAsset, glyph)
        );
        _spriteAsset.spriteInfoList.Add(spriteInfo);

        // Code PointからPrivate Use Areaへの変換用のテーブルを作る
        _codePointTable.AddEntry(frame.codePoint, privateCodePoint);
        index++;
    }

    _spriteAsset.UpdateLookupTables();
}

ネイティブ側は、絵文字のアトラスの他に、どの領域にどの絵文字があるかと言うJSONを生成するようにし(後述)、これを元に、spriteInfo、glyphを作成していく流れになります。iOSのUIKitなど、左上が(0,0)となっている座標系が多いので、そちらを基準にします。

ネイティブの実装

ネイティブ拡張をそれぞれ実装していきます。

Windows(Standalone + Editor)

まずは、Editor上ですぐに成果を確認したいので、Windows用のプラグインを作成しましょう。
Windowsのプラグインは、C#を使うものと、C++を使うものがどちらもUnityで扱えます。

C#で作成し、.Netの「System.Drawing」ライブラリ群を使うのが手っ取り早そうと思い作ってみたところ、なんと絵文字が全て黒で描画されてしまっていました...
ちゃんと調べると、Windowsで色付きの絵文字を描画するには、Direct2DのAPIを使用する必要があり、C++で実装するしかなさそうです。

メモリ管理絶対面倒だし、C++なんて書きたくない...とか思っていたんですが、プログラムの目的がシンプルというのもあり、コアな部分はAIにかなりお世話になりました。

「依存関係の解決が発生すると一気に面倒になるので、外部ライブラリを絶対に入れたくない!」とAIに無理言ってお願いしたところ、JSON保存の部分とか結構筋肉実装になりました笑

EmojiAtlas.cpp
#include "pch.h"
#include <windows.h>
#include <d2d1.h>
#include <dwrite.h>
#include <wincodec.h>
#include <string>
#include <vector>
#include <fstream>
#include <sstream>
#include <iostream>
#include <cmath>
#include <iomanip>
#include <algorithm>

#pragma comment(lib, "d2d1.lib")
#pragma comment(lib, "dwrite.lib")
#pragma comment(lib, "windowscodecs.lib")


// スマートポインタ代わりの簡易Releaseマクロ
template <class T> void SafeRelease(T** ppT) {
    if (*ppT) { (*ppT)->Release(); *ppT = NULL; }
}

// データ構造
struct EmojiFrameRect {
    float x, y, w, h;
};

struct EmojiFrame {
    std::string name;
    std::string codePoint;
    EmojiFrameRect frame;
};

// UTF-8 string to Wide string (Windows API用)
std::wstring Utf8ToWide(const std::string& str) {
    if (str.empty()) return std::wstring();
    int size_needed = MultiByteToWideChar(CP_UTF8, 0, &str[0], (int)str.size(), NULL, 0);
    std::wstring wstrTo(size_needed, 0);
    MultiByteToWideChar(CP_UTF8, 0, &str[0], (int)str.size(), &wstrTo[0], size_needed);
    return wstrTo;
}

// Wide string to UTF-8 (JSON出力用)
std::string WideToUtf8(const std::wstring& wstr) {
    if (wstr.empty()) return std::string();
    int size_needed = WideCharToMultiByte(CP_UTF8, 0, &wstr[0], (int)wstr.size(), NULL, 0, NULL, NULL);
    std::string strTo(size_needed, 0);
    WideCharToMultiByte(CP_UTF8, 0, &wstr[0], (int)wstr.size(), &strTo[0], size_needed, NULL, NULL);
    return strTo;
}

// ヘルパー: 文字列分割
std::vector<std::string> Split(const std::string& s, char delimiter) {
    std::vector<std::string> tokens;
    std::string token;
    std::istringstream tokenStream(s);
    while (std::getline(tokenStream, token, delimiter)) {
        tokens.push_back(token);
    }
    return tokens;
}

// ヘルパー: トリム
std::string Trim(const std::string& str) {
    size_t first = str.find_first_not_of(" \t\r\n");
    if (std::string::npos == first) return str;
    size_t last = str.find_last_not_of(" \t\r\n");
    return str.substr(first, (last - first + 1));
}

// コードポイント文字列 ("1F600") から UTF-16文字列を作成
std::wstring ParseCodePointsToWide(const std::string& rawString) {
    std::vector<std::string> hexParts = Split(Trim(rawString), ' ');
    std::wstring result;

    for (const auto& hex : hexParts) {
        if (hex.empty()) continue;
        try {
            unsigned long code = std::stoul(hex, nullptr, 16);

            // サロゲートペア処理
            if (code <= 0xFFFF) {
                result += (wchar_t)code;
            }
            else {
                code -= 0x10000;
                result += (wchar_t)((code >> 10) + 0xD800);
                result += (wchar_t)((code & 0x3FF) + 0xDC00);
            }
        }
        catch (...) {}
    }
    return result;
}

class EmojiAtlas {
    const float TILE_SIZE = 46.0f;
    const float FONT_SIZE = 32.0f;
    const std::wstring FONT_NAME = L"Segoe UI Emoji";

    ID2D1Factory* pD2DFactory = NULL;
    IDWriteFactory* pDWriteFactory = NULL;
    IWICImagingFactory* pWICFactory = NULL;

public:
    EmojiAtlas() {
        CoInitialize(NULL);
        D2D1CreateFactory(D2D1_FACTORY_TYPE_SINGLE_THREADED, &pD2DFactory);
        DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED, __uuidof(IDWriteFactory), reinterpret_cast<IUnknown**>(&pDWriteFactory));
        CoCreateInstance(CLSID_WICImagingFactory, NULL, CLSCTX_INPROC_SERVER, IID_IWICImagingFactory, reinterpret_cast<void**>(&pWICFactory));
    }

    ~EmojiAtlas() {
        SafeRelease(&pWICFactory);
        SafeRelease(&pDWriteFactory);
        SafeRelease(&pD2DFactory);
        CoUninitialize();
    }

    void CreateAtlas(const std::string& inputPath, const std::string& outputDir) {
        if (!pD2DFactory || !pDWriteFactory || !pWICFactory) return;

        // データ読み込み
        std::vector<std::pair<std::wstring, std::string>> emojis;
        std::ifstream inFile(inputPath);
        if (!inFile.is_open()) return;

        std::string line;
        while (std::getline(inFile, line)) {
            std::string trimmed = Trim(line);
            if (trimmed.empty() || trimmed[0] == '#') continue;

            auto parts = Split(trimmed, ';');
            if (parts.empty()) continue;

            std::string codePointPart = Trim(parts[0]);
            std::wstring emojiStr = ParseCodePointsToWide(codePointPart);

            if (!emojiStr.empty()) {
                emojis.push_back({ emojiStr, codePointPart });
            }
        }

        if (emojis.empty()) return;

        // サイズ計算
        int count = (int)emojis.size();
        int columns = (int)std::ceil(std::sqrt((float)count));
        int rows = (int)std::ceil((float)count / columns);
        int width = (int)(columns * TILE_SIZE);
        int height = (int)(rows * TILE_SIZE);

        IWICBitmap* pWicBitmap = NULL;
        pWICFactory->CreateBitmap(width, height, GUID_WICPixelFormat32bppPBGRA, WICBitmapCacheOnLoad, &pWicBitmap);

        // Render Target作成
        ID2D1RenderTarget* pRenderTarget = NULL;
        D2D1_RENDER_TARGET_PROPERTIES props = D2D1::RenderTargetProperties(
            D2D1_RENDER_TARGET_TYPE_DEFAULT,
            D2D1::PixelFormat(DXGI_FORMAT_B8G8R8A8_UNORM, D2D1_ALPHA_MODE_PREMULTIPLIED),
            0, 0, D2D1_RENDER_TARGET_USAGE_NONE, D2D1_FEATURE_LEVEL_DEFAULT
        );
        pD2DFactory->CreateWicBitmapRenderTarget(pWicBitmap, props, &pRenderTarget);

        IDWriteTextFormat* pTextFormat = NULL;
        pDWriteFactory->CreateTextFormat(
            FONT_NAME.c_str(), NULL,
            DWRITE_FONT_WEIGHT_REGULAR, DWRITE_FONT_STYLE_NORMAL, DWRITE_FONT_STRETCH_NORMAL,
            FONT_SIZE, L"en-us", &pTextFormat
        );
        pTextFormat->SetTextAlignment(DWRITE_TEXT_ALIGNMENT_CENTER);
        pTextFormat->SetParagraphAlignment(DWRITE_PARAGRAPH_ALIGNMENT_CENTER);

        ID2D1SolidColorBrush* pBrush = NULL;
        pRenderTarget->CreateSolidColorBrush(D2D1::ColorF(D2D1::ColorF::White), &pBrush);

        pRenderTarget->BeginDraw();
        pRenderTarget->Clear(D2D1::ColorF(0, 0, 0, 0)); // 透明背景

        std::vector<EmojiFrame> frames;
        int validCount = 0;

        for (int i = 0; i < count; ++i) {
            int col = validCount % columns;
            int row = validCount / columns;
            float x = col * TILE_SIZE;
            float y = row * TILE_SIZE;

            // Layout作成
            IDWriteTextLayout* pLayout = NULL;
            pDWriteFactory->CreateTextLayout(
                emojis[i].first.c_str(), (UINT32)emojis[i].first.length(),
                pTextFormat,
                TILE_SIZE * 4, // 十分大きな幅
                TILE_SIZE * 4, // 十分大きな高さ
                &pLayout
            );

            // メトリクス(実際の描画サイズ)を取得
            DWRITE_TEXT_METRICS metrics;
            pLayout->GetMetrics(&metrics);

            // 実際の幅や高さがタイルサイズを超えていたら「非対応」とみなしてスキップ
            if (metrics.width > TILE_SIZE || metrics.height > TILE_SIZE) {
                // スキップ
                SafeRelease(&pLayout);
                continue;
            }
            SafeRelease(&pLayout);
            
            pDWriteFactory->CreateTextLayout(
                emojis[i].first.c_str(), (UINT32)emojis[i].first.length(),
                pTextFormat, TILE_SIZE, TILE_SIZE, &pLayout
            );

            pRenderTarget->DrawTextLayout(
                D2D1::Point2F(x, y),
                pLayout,
                pBrush,
                D2D1_DRAW_TEXT_OPTIONS_ENABLE_COLOR_FONT
            );

            // フレームデータ保存 (有効だった場合のみ)
            frames.push_back({
                WideToUtf8(emojis[i].first),
                emojis[i].second,
                {x, y, TILE_SIZE, TILE_SIZE}
                });

            SafeRelease(&pLayout);
            validCount++;
        }

        pRenderTarget->EndDraw();

        // 保存処理
        std::string outImgPath = outputDir + "\\emoji_atlas.png";
        std::string outJsonPath = outputDir + "\\emoji_atlas.json";

        // ディレクトリ作成
        CreateDirectoryA(outputDir.c_str(), NULL);

        SaveImage(pWicBitmap, outImgPath);
        SaveJson(frames, outJsonPath);

        // Release
        SafeRelease(&pBrush);
        SafeRelease(&pTextFormat);
        SafeRelease(&pRenderTarget);
        SafeRelease(&pWicBitmap);
    }

private:
    void SaveImage(IWICBitmap* pBitmap, const std::string& path) {
        IWICStream* pStream = NULL;
        IWICBitmapEncoder* pEncoder = NULL;
        IWICBitmapFrameEncode* pFrame = NULL;

        // Wideパスへ変換
        std::wstring wPath = Utf8ToWide(path);

        pWICFactory->CreateStream(&pStream);
        pStream->InitializeFromFilename(wPath.c_str(), GENERIC_WRITE);

        pWICFactory->CreateEncoder(GUID_ContainerFormatPng, NULL, &pEncoder);
        pEncoder->Initialize(pStream, WICBitmapEncoderNoCache);

        pEncoder->CreateNewFrame(&pFrame, NULL);
        pFrame->Initialize(NULL);

        UINT w, h;
        pBitmap->GetSize(&w, &h);
        pFrame->SetSize(w, h);

        WICPixelFormatGUID format = GUID_WICPixelFormat32bppPBGRA;
        pFrame->SetPixelFormat(&format);

        pFrame->WriteSource(pBitmap, NULL);
        pFrame->Commit();
        pEncoder->Commit();

        SafeRelease(&pFrame);
        SafeRelease(&pEncoder);
        SafeRelease(&pStream);
    }

    void SaveJson(const std::vector<EmojiFrame>& frames, const std::string& path) {
        std::ofstream out(path);
        if (!out.is_open()) return;

        out << "{\n";
        out << "  \"imageName\": \"emoji_atlas.png\",\n";
        out << "  \"frames\": [\n";

        for (size_t i = 0; i < frames.size(); ++i) {
            const auto& f = frames[i];
            out << "    {\n";
            out << "      \"name\": \"" << f.name << "\",\n";
            out << "      \"codePoint\": \"" << f.codePoint << "\",\n";
            out << "      \"frame\": {\n";
            out << "        \"x\": " << f.frame.x << ",\n";
            out << "        \"y\": " << f.frame.y << ",\n";
            out << "        \"w\": " << f.frame.w << ",\n";
            out << "        \"h\": " << f.frame.h << "\n";
            out << "      }\n";
            out << "    }" << (i < frames.size() - 1 ? "," : "") << "\n";
        }
        out << "  ]\n";
        out << "}";
        out.close();
    }
};

extern "C" {
    __declspec(dllexport) void create_emoji_atlas(const char* inputPath, const char* outputDir) {
        EmojiAtlas emojiAtlas;
        emojiAtlas.CreateAtlas(std::string(inputPath), std::string(outputDir));
    }
}

最初、時に制約を入れずに絵文字を描画していたところ、一部のZWJを使った絵文字が複数文字として描画され、他の絵文字の領域にはみ出ると言う問題が起きました。

上の章でもZWJ絵文字について軽く説明しましたが、例えば👨🏻‍🦱と言う絵文字は、👨と🦱(と、これらを連結するための制御コード)の二つの絵文字が合体したものです。
OS(のレンダリングエンジン)やフォントのバージョン最新であれば、絵文字は一文字として描画できますが、それらが古く対応していない場合、組み合わさる前の複数の絵文字に分解されて描画されてしまいます。
手元のWindowsでは、emoji-test.txtに含まれているいくつかの絵文字が描画できず、この状態になっていたため、はみ出していました。そこで、描画された時の幅を計算し、長かったら対応していない文字という扱いにしています。

// 実際の幅や高さがタイルサイズを超えていたら「非対応」とみなしてスキップ
if (metrics.width > TILE_SIZE || metrics.height > TILE_SIZE) {
    // スキップ
    SafeRelease(&pLayout);
    continue;
}

完成したコードは、ビルドして、dllとしてUnityのプロジェクトに配置します。

iOS

Windowsのコードをベースに、iOS用にいい感じで書き換えてくれ、とAIにお願いすると、概ね良さそうなコードが出てきました。便利ですね。

EmojiAtlas.swift
import UIKit

struct FrameRect: Encodable {
    let x: CGFloat
    let y: CGFloat
    let w: CGFloat
    let h: CGFloat
}

struct EmojiFrame: Encodable {
    let name: String
    let codePoint: String
    let frame: FrameRect
}

struct AtlasMetadata: Encodable {
    let imageName: String
    let frames: [EmojiFrame]
}


@_cdecl("create_emoji_atlas")
public func createEmojiAtlasWrapper(inputPath: UnsafePointer<CChar>, outputDir: UnsafePointer<CChar>) {
    let inputPathString = String(cString: inputPath)
    let outputDirString = String(cString: outputDir)
    
    let emojiAtlas = EmojiAtlas()
    emojiAtlas.createAtlas(inputPath: inputPathString, outputDirectory: outputDirString)
}

class EmojiAtlas {
    private let tileSize: CGFloat = 34
    private let fontSize: CGFloat = 32
    private let outputImageName = "emoji_atlas.png"
    private let outputJsonName = "emoji_atlas.json"

    public func createAtlas(inputPath: String, outputDirectory: String) {
        guard let emojis = loadEmojis(from: inputPath) else {
            return
        }

        let count = CGFloat(emojis.count)
        let columns = ceil(sqrt(count))
        let rows = ceil(count / columns)
        
        let atlasWidth = columns * tileSize
        let atlasHeight = rows * tileSize
        let atlasSize = CGSize(width: atlasWidth, height: atlasHeight)
        
        var frames: [EmojiFrame] = []
        let renderer = UIGraphicsImageRenderer(size: atlasSize)
        
        let atlasImage = renderer.image { context in
            let font = UIFont.systemFont(ofSize: fontSize)
            let paragraphStyle = NSMutableParagraphStyle()
            paragraphStyle.alignment = .center
            
            let attributes: [NSAttributedString.Key: Any] = [
                .font: font,
                .paragraphStyle: paragraphStyle
            ]
            
            var validCount = 0
            for (index, item) in emojis.enumerated() {
                let emoji = item.char
                let col = CGFloat(index % Int(columns))
                let row = CGFloat(index / Int(columns))
                
                let x = col * tileSize
                let y = row * tileSize
                
                let stringSize = (emoji as NSString).size(withAttributes: attributes)
                if stringSize.width > tileSize {
                    print("未対応の絵文字: \(emoji)")
                    continue
                }
                
                let drawRect = CGRect(
                    x: x,
                    y: y + (tileSize - stringSize.height) / 2,
                    width: tileSize,
                    height: stringSize.height
                )
                
                (emoji as NSString).draw(in: drawRect, withAttributes: attributes)
                
                let frameData = EmojiFrame(
                    name: emoji,
                    codePoint: item.codePoint,
                    frame: FrameRect(x: x, y: y, w: tileSize, h: tileSize)
                )
                frames.append(frameData)
                validCount += 1
            }
        }
        
        save(image: atlasImage, frames: frames, to: outputDirectory)
    }

    private func loadEmojis(from path: String) -> [(char: String, codePoint: String)]? {
        let url = URL(fileURLWithPath: path)

        do {
            let content = try String(contentsOf: url, encoding: .utf8)
            let lines = content.components(separatedBy: .newlines)
            
            var validEmojis: [(char: String, codePoint: String)] = []
            
            for line in lines {
                let trimmedLine = line.trimmingCharacters(in: .whitespaces)
                if trimmedLine.isEmpty || trimmedLine.hasPrefix("#") {
                    continue
                }
                
                let parts = trimmedLine.components(separatedBy: ";")
                guard let codePointPart = parts.first else { continue }
                
                if let emoji = parseCodePoints(codePointPart) {
                    let cleanCodePoint = codePointPart.trimmingCharacters(in: .whitespaces)
                    validEmojis.append((char: emoji, codePoint: cleanCodePoint))
                }
            }
            return validEmojis.isEmpty ? nil : validEmojis
            
        } catch {
            print("ファイル読み込みエラー: \(error)")
            return nil
        }
    }

    private func parseCodePoints(_ rawString: String) -> String? {
        let hexParts = rawString.trimmingCharacters(in: .whitespaces).components(separatedBy: " ")
        var scalars: [UnicodeScalar] = []
        
        for hex in hexParts {
            if hex.isEmpty { continue }
            if let code = UInt32(hex, radix: 16), let scalar = UnicodeScalar(code) {
                scalars.append(scalar)
            }
        }
        
        if scalars.isEmpty { return nil }
        
        var emojiString = ""
        emojiString.unicodeScalars.append(contentsOf: scalars)
        return emojiString
    }

    private func save(image: UIImage, frames: [EmojiFrame], to directoryPath: String) {
        let metadata = AtlasMetadata(imageName: outputImageName, frames: frames)
        let encoder = JSONEncoder()
        encoder.outputFormatting = .prettyPrinted

        guard let jsonData = try? encoder.encode(metadata),
              let pngData = image.pngData() else {
            print("データ変換に失敗しました")
            return
        }

        // ディレクトリURLの構築
        let outputUrl = URL(fileURLWithPath: directoryPath, isDirectory: true)
        
        // ディレクトリが存在しない場合は作成を試みる(オプション)
        try? FileManager.default.createDirectory(at: outputUrl, withIntermediateDirectories: true)

        let imagePath = outputUrl.appendingPathComponent(outputImageName)
        let jsonPath = outputUrl.appendingPathComponent(outputJsonName)

        do {
            try pngData.write(to: imagePath)
            try jsonData.write(to: jsonPath)
        } catch {
            print("ファイル書き込みエラー: \(error)")
        }
    }
}

iOSでは、Swiftのソースコードがそのままプラグインとして扱える(ビルド時に、C#のAssemblyと同じバイナリにリンクされる)ので、swiftファイルをそのままUnityに入れます。

macOS(Standalone + Editor)

macOSは、iOSとほぼ同じですが、UIKitが使えないので、描画方法が若干違います

EmojiAtlas.swift
import Cocoa
import Foundation

struct FrameRect: Encodable {
    let x: CGFloat
    let y: CGFloat
    let w: CGFloat
    let h: CGFloat
}

struct EmojiFrame: Encodable {
    let name: String
    let codePoint: String
    let frame: FrameRect
}

struct AtlasMetadata: Encodable {
    let imageName: String
    let frames: [EmojiFrame]
}

@_cdecl("create_emoji_atlas")
public func createEmojiAtlasWrapper(inputPath: UnsafePointer<CChar>, outputDir: UnsafePointer<CChar>) {
    let inputPathString = String(cString: inputPath)
    let outputDirString = String(cString: outputDir)
    
    let render = EmojiAtlas()
    render.createAtlas(inputPath: inputPathString, outputDirectory: outputDirString)
}

class EmojiAtlas {
    // 設定値
    private let tileSize: CGFloat = 34
    private let fontSize: CGFloat = 32
    private let outputImageName = "emoji_atlas.png"
    private let outputJsonName = "emoji_atlas.json"

    public func createAtlas(inputPath: String, outputDirectory: String) {
        guard let emojis = loadEmojis(from: inputPath) else {
            return
        }

        let count = CGFloat(emojis.count)
        let columns = ceil(sqrt(count))
        let rows = ceil(count / columns)
        
        let atlasWidth = columns * tileSize
        let atlasHeight = rows * tileSize
        let atlasSize = CGSize(width: atlasWidth, height: atlasHeight)
        
        var frames: [EmojiFrame] = []
        
        let font = NSFont.systemFont(ofSize: self.fontSize)
        let paragraphStyle = NSMutableParagraphStyle()
        paragraphStyle.alignment = .center
        let attributes: [NSAttributedString.Key: Any] = [
            .font: font,
            .paragraphStyle: paragraphStyle,
            .foregroundColor: NSColor.black
        ]

        var validCount = 0
        for (_, item) in emojis.enumerated() {
            let emoji = item.char
            let stringSize = (emoji as NSString).size(withAttributes: attributes)
            if stringSize.width > self.tileSize {
                print("未対応の絵文字: \(emoji)")
                continue
            }
            let col = CGFloat(validCount % Int(columns))
            let row = CGFloat(validCount / Int(columns))
            
            let x = col * self.tileSize
            let y = row * self.tileSize
            
            let frameData = EmojiFrame(
                name: emoji,
                codePoint: item.codePoint,
                frame: FrameRect(x: x, y: y, w: self.tileSize, h: self.tileSize)
            )
            frames.append(frameData)
            validCount += 1
        }
        
        let atlasImage = NSImage(size: atlasSize, flipped: true) { (rect) -> Bool in
            for frameData in frames {
                let emoji = frameData.name
                let frameRect = frameData.frame
                
                let stringSize = (emoji as NSString).size(withAttributes: attributes)
                
                let drawRect = CGRect(
                    x: frameRect.x,
                    y: frameRect.y + (self.tileSize - stringSize.height) / 2,
                    width: self.tileSize,
                    height: stringSize.height
                )
                
                (emoji as NSString).draw(in: drawRect, withAttributes: attributes)
            }
            return true
        }
        save(image: atlasImage, frames: frames, to: outputDirectory)
    }

    private func loadEmojis(from path: String) -> [(char: String, codePoint: String)]? {
        let url = URL(fileURLWithPath: path)

        do {
            let content = try String(contentsOf: url, encoding: .utf8)
            let lines = content.components(separatedBy: .newlines)
            
            var validEmojis: [(char: String, codePoint: String)] = []
            
            for line in lines {
                let trimmedLine = line.trimmingCharacters(in: .whitespaces)
                if trimmedLine.isEmpty || trimmedLine.hasPrefix("#") {
                    continue
                }
                
                let parts = trimmedLine.components(separatedBy: ";")
                guard let codePointPart = parts.first else { continue }
                
                if let emoji = parseCodePoints(codePointPart) {
                    let cleanCodePoint = codePointPart.trimmingCharacters(in: .whitespaces)
                    validEmojis.append((char: emoji, codePoint: cleanCodePoint))
                }
            }
            return validEmojis.isEmpty ? nil : validEmojis
            
        } catch {
            print("ファイル読み込みエラー: \(error)")
            return nil
        }
    }

    private func parseCodePoints(_ rawString: String) -> String? {
        let hexParts = rawString.trimmingCharacters(in: .whitespaces).components(separatedBy: " ")
        var scalars: [UnicodeScalar] = []
        
        for hex in hexParts {
            if hex.isEmpty { continue }
            if let code = UInt32(hex, radix: 16), let scalar = UnicodeScalar(code) {
                scalars.append(scalar)
            }
        }
        
        if scalars.isEmpty { return nil }
        
        var emojiString = ""
        emojiString.unicodeScalars.append(contentsOf: scalars)
        return emojiString
    }

    private func save(image: NSImage, frames: [EmojiFrame], to directoryPath: String) {
        let metadata = AtlasMetadata(imageName: outputImageName, frames: frames)
        let encoder = JSONEncoder()
        encoder.outputFormatting = .prettyPrinted

        // NSImage -> TIFF -> BitmapRep -> PNGデータ の順に変換
        guard let tiffData = image.tiffRepresentation,
              let bitmapRep = NSBitmapImageRep(data: tiffData),
              let pngData = bitmapRep.representation(using: .png, properties: [:]) else {
            print("画像データの変換に失敗しました")
            return
        }
        
        guard let jsonData = try? encoder.encode(metadata) else {
            print("JSONエンコードに失敗しました")
            return
        }

        let outputUrl = URL(fileURLWithPath: directoryPath, isDirectory: true)
        try? FileManager.default.createDirectory(at: outputUrl, withIntermediateDirectories: true)

        let imagePath = outputUrl.appendingPathComponent(outputImageName)
        let jsonPath = outputUrl.appendingPathComponent(outputJsonName)

        do {
            try pngData.write(to: imagePath)
            try jsonData.write(to: jsonPath)
        } catch {
            print("ファイル書き込みエラー: \(error)")
        }
    }
}

macOSでは、iOSのようにswiftファイルをそのままでは使えないので、bundleにビルドした上で、Unityのプロジェクトに入れます。

Android

Androidは、UnityのC#からAndroidJavaClassを使ってJavaのクラスを呼び出せるので、Javaのプラグインを作成し、Unityのプロジェクト内に格納します。

「android.graphics」のAPI群を使い、ビットマップに文字を描画することができます。リストから取得した絵文字を全てビットマップに描画した後、PNGとして保存し、これをUnity側で使うアトラスにします。

EmojiAtlas.java
package com.wakapippi;

import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.util.Log;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileOutputStream;
import java.io.FileReader;
import java.io.FileWriter;
import java.util.ArrayList;
import java.util.List;
import org.json.JSONArray;
import org.json.JSONObject;

public class EmojiAtlas {

    private static final int TILE_SIZE = 34;
    private static final int FONT_SIZE = 28;
    private static final String OUTPUT_IMAGE_NAME = "emoji_atlas.png";
    private static final String OUTPUT_JSON_NAME = "emoji_atlas.json";

    // データ保持用クラス
    private static class EmojiData {
        String charStr;
        String codePoint;
        float x, y;

        EmojiData(String charStr, String codePoint) {
            this.charStr = charStr;
            this.codePoint = codePoint;
        }
    }

    // Unityから呼び出すメソッド
    public static void createAtlas(String inputPath, String outputDir) {
        try {
            List<EmojiData> emojis = loadEmojis(inputPath);
            if (emojis == null || emojis.isEmpty()) {
                Log.e("EmojiAtlas", "絵文字データが見つかりません");
                return;
            }

            int count = emojis.size();
            int columns = (int) Math.ceil(Math.sqrt(count));
            int rows = (int) Math.ceil((double) count / columns);

            int atlasWidth = columns * TILE_SIZE;
            int atlasHeight = rows * TILE_SIZE;

            // ビットマップの作成
            Bitmap bitmap = Bitmap.createBitmap(atlasWidth, atlasHeight, Bitmap.Config.ARGB_8888);
            Canvas canvas = new Canvas(bitmap);
            
            // 描画設定
            Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
            paint.setTextSize(FONT_SIZE);
            paint.setColor(Color.BLACK); 
            paint.setTextAlign(Paint.Align.CENTER);
            
            // 背景クリア
            canvas.drawColor(Color.TRANSPARENT);

            Paint.FontMetrics metrics = paint.getFontMetrics();
            float textHeightOffset = (metrics.descent + metrics.ascent) / 2;

            List<EmojiData> validFrames = new ArrayList<>();
            int validCount = 0;

            for (int i = 0; i < count; i++) {
                EmojiData item = emojis.get(i);

                float textWidth = paint.measureText(item.charStr);
                
                if (textWidth > TILE_SIZE) {
                    Log.d("EmojiAtlas", "未対応の絵文字: " + item.charStr);
                    continue;
                }

                int col = validCount % columns;
                int row = validCount / columns;

                float x = col * TILE_SIZE;
                float y = row * TILE_SIZE;
                
                // データの座標を更新
                item.x = x;
                item.y = y;

                float centerX = x + (TILE_SIZE / 2f);
                float centerY = y + (TILE_SIZE / 2f);
                
                canvas.drawText(item.charStr, centerX, centerY - textHeightOffset, paint);
                
                validFrames.add(item);
                validCount++;
            }
            save(bitmap, validFrames, outputDir);
            
            if (!bitmap.isRecycled()) {
                bitmap.recycle();
            }

        } catch (Exception e) {
            Log.e("EmojiAtlas", "エラー発生: " + e.getMessage());
            e.printStackTrace();
        }
    }

    private static List<EmojiData> loadEmojis(String path) {
        File file = new File(path);
        if (!file.exists()) return null;
        List<EmojiData> validEmojis = new ArrayList<>();
        try (BufferedReader br = new BufferedReader(new FileReader(file))) {
            String line;
            while ((line = br.readLine()) != null) {
                String trimmed = line.trim();
                if (trimmed.isEmpty() || trimmed.startsWith("#")) continue;
                String[] parts = trimmed.split(";");
                if (parts.length == 0) continue;
                String codePointPart = parts[0].trim();
                String emoji = parseCodePoints(codePointPart);
                if (emoji != null && !emoji.isEmpty()) {
                    validEmojis.add(new EmojiData(emoji, codePointPart));
                }
            }
        } catch (Exception e) { return null; }
        return validEmojis;
    }

    private static String parseCodePoints(String rawString) {
        try {
            String[] hexParts = rawString.trim().split("\\s+");
            StringBuilder sb = new StringBuilder();
            for (String hex : hexParts) {
                if (hex.isEmpty()) continue;
                int code = Integer.parseInt(hex, 16);
                sb.append(new String(Character.toChars(code)));
            }
            return sb.toString();
        } catch (Exception e) { return null; }
    }

    private static void save(Bitmap bitmap, List<EmojiData> frames, String outputDir) throws Exception {
        File dir = new File(outputDir);
        if (!dir.exists()) dir.mkdirs();

        File imageFile = new File(dir, OUTPUT_IMAGE_NAME);
        try (FileOutputStream out = new FileOutputStream(imageFile)) {
            bitmap.compress(Bitmap.CompressFormat.PNG, 100, out);
        }

        JSONObject root = new JSONObject();
        root.put("imageName", OUTPUT_IMAGE_NAME);

        JSONArray frameArray = new JSONArray();
        for (EmojiData f : frames) {
            JSONObject frameObj = new JSONObject();
            frameObj.put("name", f.charStr);
            frameObj.put("codePoint", f.codePoint);

            JSONObject rect = new JSONObject();
            rect.put("x", (double)f.x);
            rect.put("y", (double)f.y);
            rect.put("w", (double)TILE_SIZE);
            rect.put("h", (double)TILE_SIZE);
            
            frameObj.put("frame", rect);
            frameArray.put(frameObj);
        }
        root.put("frames", frameArray);

        File jsonFile = new File(dir, OUTPUT_JSON_NAME);
        try (FileWriter writer = new FileWriter(jsonFile)) {
            writer.write(root.toString(2));
        }
    }
}

AndroidもiOS同様で、javaのソースコードをプラグインとして扱えるので、javaファイルをそのままUnityのプロジェクトに入れます。

その他のコードについて

コードポイントの変換テーブルや、プラットフォームごとの呼び出しの切り分けなどはここでは割愛していますが、今回の記事のために用意したサンプルプロジェクトをGitHubで公開していますので、気になる方はそちらをご覧ください。
https://github.com/wakapippi/UnityEmojiExperiment

ネイティブのコードも同じリポジトリに入っています。

成果

プラットフォームごとに、次のように絵文字が表示されました。

Windows

Editorで表示したものは次のようになりました。Standaloneのビルドでも同様に動きます。
スクリーンショット 2025-12-17 21.22.41.png

macOS / iOS

Editorで表示したものは次のようになりました。Standaloneのビルドでも同様に動きます。iOSとフォントが同じなので、iOSでも同じような表示になります。
スクリーンショット 2025-12-17 21.15.10.png

Android

Android(エミュレータ)でも、ネイティブのフォントを使って絵文字を描画できることを確認できます。
スクリーンショット 2025-12-17 21.24.30.png

課題

アドベントカレンダーのために短い時間で実装したと言うこともあり、今回は最低限絵文字が描画できることを確認できるところまでの実装で終えています。実際にプロジェクトなどで使うには次のような課題があると思います。

アトラス生成・Sprite Asset生成の処理が重い

アトラス生成は、ネイティブ側で大量の絵文字を描画するため、処理に大変時間がかかります。一回キャッシュしてしまえば再度実行されないとはいえ、初回実行は時間がかかります。ゲームを起動してすぐ絵文字を描画する、というケースはそんなにないと思うので、この処理は、スレッドプールなどで行い、メインスレッドをブロックしないようにするのが良さそうです。
また、Sprite Assetの生成も、大量の絵文字を追加している関係で、処理が遅いです。こちらはUnityのAPIを使う関係で、メインスレッドで実行する必要はありますが、何も1フレームで全てを完了させる必要はないので、複数のフレームに分割して処理を行うと良さそうです。

各種APIを提供するとき、非同期APIとして提供できるといいですね。

絵文字と普通の文字を混合した時に、上下方向の位置が揃っていない

フォントによって、絵文字の描画位置が上下方向に若干ズレることがあります。ネイティブのレンダリングエンジンであれば、フォント内の位置を調整するための情報を読み取ってうまく描画できますが、今回の方法ではそうもいきません。
スクリーンショット 2025-12-17 22.02.48.png

図のように、Sprite AssetのGlyph Tableにある、BXとBYのパラメータを変更することで位置を調整できます。
ここの数値は、OSごとに最適な値がありそうなので、スクリプトで一括調整すれば良さそうです。

一部のハードコードをどうにかする

今回では、例えば、アトラスを描画する時の文字サイズを、ネイティブのプラグイン側にハードコードしています。場面によってはより高解像度な絵文字を描画したいこともあると思うので、その辺を外部から設定できるようにする必要がありそうです。

おわりに

Unityでネイティブの絵文字を描画したい、という目標に向けて色々調査したり実装したりしていたんですが、気づいたら結構長い内容になっていました。

本文ではあまり触れていないのですが、絵文字の仕様は大変複雑で、かなり学びがありました。絵文字に限らず、Unicodeの文字の扱い全般、フォントの仕組みどれを取っても複雑で、先人の努力が垣間見えます。

普段からUnityでゲームなどをゴリゴリ実装している人でも、たまにはこのように少し逸れた分野の実装をしてみるのも面白いな、と思いました。この記事が少しでも皆様の役に立てると幸いです。

11
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?