【Unity】日本語テキストの自動改行

1 はじめに

こんにちは。ソーシャルゲーム事業部の額田です。 この記事はカヤックUnityアドベントカレンダー2018の21日目の記事です。 今回はTextコンポーネントでの自動改行についてお話ししていきたいと思います。

日本語のテキストを表示するときに文章の区切りとしては不自然な箇所で改行されてしまい、 TextViewのサイズに合わせて\nを自前で入れた経験はないでしょうか? 変更しないテキストならよいですが、セリフなど動的に変更するテキストの場合は、自前で改行文字を入れるのは労力がかかります。 なので、GameObjectのサイズに合わせて、いい感じに改行してくれるTextコンポーネントを作ってみたいと思います。

// Before
我輩は猫である。名前はま
だない。
// After
吾輩は猫である。名前は\n
まだない。

プロジェクトによっては「このTextにはデザインの都合上200文字しか表示したくない」というケースもあります。 そのような場合に、意図しない改行で文字制限を超えてしまうという問題も生じるかと思います。 今回は自動改行をする1つのやり方を紹介するに留め、その辺りは考慮しないこととします。

2 UnityのTextコンポーネント

2.1 いい感じに改行ができない理由

UnityのTextコンポーネントでは、英語のように単語と単語の間に空白がある言語は空白を区切りにして改行が行われます。

f:id:nu-nuchi:20181219111250g:plain

しかし、日本語や中国語、韓国語など空白を使わない言語の場合は改行をする目印がありません。 そのため、1 はじめに で述べたように不自然な位置で改行が行われてしまいます。

f:id:nu-nuchi:20181219111307g:plain

2.2 改行をするためには?

わかち書き

わかち書きとは空白を用いて単語や文節を区切って記述する方法です。 昔の某RPGゲームなどを遊んだことがある人には懐かしい文章だと思います。 ひらがな文章の場合に使われたりしますが、通常は使われない記述方法です。

空白で明示的に区切り位置を指定することができ、英語文章と同じく空白区切りでの改行をすることが可能になります。 ですが、可読性はよくないためオススメしません。

f:id:nu-nuchi:20181219111335g:plain

あきらめて かわいさ ろせんを めざし この ほうほうで げーむを つくるのも ありかも しれません。

日本語に不慣れな外国人あるいは日本語学習者がひらがなを主体としたわかち書きの文章を用いる例があり[2]、そのたどたどしいさまが、けなげさ、可愛らしさにつながりより深い共感を生む場合がある[3]。

Wikipedia - わかち書き

形態素解析

空白を用いずに単語や文節の単位で区切って改行をするためには、文章の中でどこからどこまでが意味のまとまった語句なのかを知る必要があります。

「が」「を」「に」などを目印に意味のまとまりを抽出するという方法も考えられますが、 日本語は複雑であるためアドホックな方法では限界があります。

これを実現するには 形態素解析 を行う方法があります。 簡単に言うと、文章を形態素と呼ばれるその言語の最小単位に分解して、それぞれの品詞が何かというのを解析します。

形態素解析を行うことで、意味のある最小単位で区切ることができます。 また、品詞情報を使うことで文節単位で改行をするなどより柔軟な改行も可能になると思います。

文字列読み原形品詞の種類活用の種類活用形
お待ちオマチお待ち名詞-サ変接続  
する動詞-自立サ変・スル連用形
助詞-接続助詞  
おりオリおる動詞-非自立五段・ラ行連用形
ますマスます助動詞特殊・マス基本形
記号-句点  
Wikipedia - わかち書き

実際にUnityで形態素解析をする方法については3 形態素解析で紹介し、 4 自動改行させようではTextコンポーネントを継承したクラスで簡易的に自動改行を行ってみたいと思います。

3 形態素解析

3.1 MeCabとは

形態素解析を行うオープンソースにMeCabがあります。 今回はUnity上で行うために、MeCabを.NETに移植したNMeCabを使用します。

導入と使い方について、本記事だけで読み進められるよう簡単な説明をしていきますが、 以下のサイトを参考にしていただくとよいかと思います。 - Unityで形態素解析をする方法 - Qiita - NMeCabで形態素解析をしてみよう - Qiita

3.1 導入

NMeCabの最新版(現時点ではNMeCab 0.07)を ここからダウンロードしてきます。 必要なのは以下の2つです。 - NMeCab0.07/dic - NMeCab0.07/bin/LibNMeCab.dll

適当にNMeCabディレクトリなどをUnity側に用意して、これらを入れておきましょう。

f:id:nu-nuchi:20181219111303p:plain

3.2 使い方

まずは簡単なサンプルコードを動かしてみましょう。

  1. MeCabParamに形態素解析で使用する辞書を設定します。 先ほど配置したNMeCab/dic/ipadicを指定します。 なお、今回はUnityEditor上での実行を想定しています。 必要に応じて適切なファイルの配置とパスの指定を行なってください
MeCabParam param = new MeCabParam();
param.DicDir = @"Assets/NMeCab/dic/ipadic";
  1. 文章を解析するためのMeCabTaggerを生成し、文章を解析して形態素に分解します。 各形態素は双方向リストになっており、node.Nextに次のノードの参照が入っています。 node.Surfaceには形態素の文字列が入っています。 node.Featureには解析結果として、素性(品詞や活用形などの解析情報)がCSV形式の文字列で入っています。
MeCabTagger tagger = MeCabTagger.Create(param);
string text = "吾輩は猫である。名前はまだない。";
MeCabNode node = tagger.ParseToNode(text);
while (node != null)
{
    // 文頭と文末にIDのない空ノードがあるためそれ以外を対象とする
    if (node.PosId != 0)
    {
        Debug.Log(node.Surface + "," + node.Feature);
    }
    node = node.Next;
}

以下が全ソースコードです。これを空のGameObjectにアタッチしてUnityを実行してみましょう。

using UnityEngine;
using NMeCab;

public class Sample : MonoBehaviour
{
    void Start()
    {
        MeCabParam param = new MeCabParam();
        // 導入で置いたdic以下の辞書ディレクトリを指定
        param.DicDir = @"Assets/NMeCab/dic/ipadic";
        MeCabTagger tagger = MeCabTagger.Create(param);

        string text = "吾輩は猫である。名前はまだない。";
        MeCabNode node = tagger.ParseToNode(text);
        while (node != null)
        {
            // 文頭と文末にIDのない空ノードがあるためそれ以外を対象とする
            if (node.PosId != 0)
            {
                Debug.Log(node.Surface + "," + node.Feature);
            }
            node = node.Next;
        }
    }
}

f:id:nu-nuchi:20181219111316p:plain

4 自動改行させよう

それでは実際にTextコンポーネントを継承して、新しいコンポーネントを作っていこうと思います。

4.1 サンプルコード

Textが継承しているGraphicクラスに ExecuteInEditMode 属性がついているため、Updateメソッドに改行処理を書けばEditorで動的に改行されるのが確認できます。

以下がソースコードです。Textコンポーネントの代わりにこのコンポーネントをアタッチして動かしてみてください。

using System.Text;
using UnityEngine;
using UnityEngine.UI;
using UnityEditor;
using NMeCab;
using NMeCab.Extension.IpaDic;

public class WordWrapText : Text
{
    private MeCabTagger _tagger;
    private MeCabNode _node;
    private Rect _rect;

    /// <summary>
    /// Textの横幅
    /// </summary>
    private float _rectWidth { get { return this.rectTransform.sizeDelta.x; } }

    /// <summary>
    /// テキストを更新したときに形態素解析する
    /// </summary>
    public override string text
    {
        set
        {
            base.text = value;
            _node = Parse(value);
        }
    }

    /// <summary>
    /// Inspectorからテキストを変更した際に形態素解析する
    /// </summary>
    private void OnValidate()
    {
        _node = Parse(this.text);
    }

    private void Awake()
    {
        _node = Parse(this.text);
    }

    private void SetTagger()
    {
        MeCabParam param = new MeCabParam();
        param.DicDir = @"Assets/NMecab/dic/ipadic";
        _tagger = MeCabTagger.Create(param);
    }

    private MeCabNode Parse(string text)
    {
        if (_tagger == null) { SetTagger(); }
        return _tagger.ParseToNode(text.Replace("\n", ""));
    }

    /// <summary>
    /// preferredWidthを計算する
    /// [参考URL] https://bitbucket.org/Unity-Technologies/ui/src/9f418c4767c47d0c71f1727eb42a9a9024e9ecc0/UnityEngine.UI/UI/Core/Text.cs?fileviewer=file-view-default#Text.cs-502:509
    /// </summary>
    private float CalcPreferredWidth(string text)
    {
        var settings = GetGenerationSettings(Vector2.zero);
        return cachedTextGeneratorForLayout.GetPreferredWidth(text, settings) / pixelsPerUnit;
    }

    private void Update()
    {
        if (_node == null) { return; }

        // rectに変更がなければ更新を行わない
        if (rectTransform.rect.Equals(_rect)) { return; }
        _rect = rectTransform.rect;

        // 改行文字を入れて、形態素の文字列を連結していく
        MeCabNode node = _node;
        StringBuilder builder = new StringBuilder();
        string accumulator = string.Empty;
        while (node != null)
        {
            if (node.PosId != 0)
            {
                // 改行する長さになるまでaccumulatorに蓄積していく
                string newText = accumulator + node.Surface;
                if (CalcPreferredWidth(newText) < _rectWidth)
                {
                    accumulator = newText;
                }
                else
                {
                    builder.AppendLine(accumulator);
                    accumulator = node.Surface;
                }
            }
            node = node.Next;
        }
        builder.Append(accumulator);
        this.text = builder.ToString();
    }
}

f:id:nu-nuchi:20181219111344g:plain

比較として、以下が通常のTextコンポーネントです。

f:id:nu-nuchi:20181219111319g:plain

4.2 解説

横幅に文字列が収まるかどうかは preferredWidth とRectTransformの横幅を比較しています。 TextのpreferredWidthは自身のtextのサイズを計算するため、実際にテキストを更新するまで取得できません。 そこで、任意の文字列に対してpreferredWidthを計算するためのCalcPreferredWidthを定義しました。 node.Surfaceを追加していき、まだ収まるなら蓄積し、収まらなくなったら改行文字を入れています。

// 改行する長さになるまでaccumulatorに蓄積していく
string newText = accumulator + node.Surface;
if (CalcPreferredWidth(newText) < _rectWidth)
{
    accumulator = newText;
}
else
{
    builder.AppendLine(accumulator);
    accumulator = node.Surface;
}

4.3 発展

4 自動改行させようのコンポーネントでは句読点も改行の区切り対象になっています。 そのため、「。」だけであっても改行されてしまっています。 日本語の文書では、このように行頭に句読点が位置するのは避けるべきとされています。 そのために字詰などをすることを 禁則処理 と言います。

形態素解析により文章を最小の単位に分解し、それらの品詞を取得することで禁則処理やより柔軟な改行を行うことができます。 今回紹介したコンポーネントを改良して禁則処理を追加してみても面白いかもしれません! 興味のある方はぜひやってみてください!

(MeCabでは「。」や「、」は品詞が記号となっています。 Tipsでは、形態素の品詞の取得の仕方について簡単に補足しています。)

Tips

◆NMeCabの品詞の取得の仕方 形態素の品詞はnode.FeatureにCSV形式で入っています。 それを","で分割して品詞の入っているカラムを取り出してもいいですが、 NMeCabに拡張が入っているのでそれを使ってみましょう。

拡張のソースコードは3.1 導入でダウンロードした NMeCab0.07/src/LibNMeCab/Extensionに入っているので、UnityのAssets/NMeCabに入れます。

拡張のソースコードは#if EXT ~ #endifで囲われているので、#defineディレクティブにEXTを追加しましょう。 ここ を参考にPlayerSettingsのOtherSettingsパネルから追加してください。

NMeCabNodeにGetPartsOfSpeechという拡張メソッドが追加され、簡単に品詞を取得することができるようになりました。

using NMeCab.Extension.IpaDic;

string sentence = "今日はいい天気です。";
var node = _tagger.ParseToNode(sentence);
while (node != null)
{
    if (node.PosId != 0)
    {
        var surface = node.Surface;
        var partsOfSpeech = node.GetPartsOfSpeech();
        Debug.Log(surface + ":" + partsOfSpeech);
    }
    node = node.Next;
}

f:id:nu-nuchi:20181219111259p:plain

おわりに

Unityで日本語の自動改行をMeCabを使ってやってみました。 今回はHowToということで確認用にUpdateで書いていたり、モバイルでの動作は考慮していないので実用化するにはもう一手間かかると思いますが、興味を持っていただければ幸いです。

明日は藤井さんの「簡単に水に近い表現を実現したい」です!

regl入門

f:id:suzu_kun:20181217172535p:plain

こんにちは、カヤック・クライアントワークチーム・フロントエンドエンジニアのMr.ブラウンです。
今回は、
Fast functional WebGL
と謳っているreglを紹介いたします。
※ 多少、WebGL・GLSLをさわったことがある前提で話が進んでいきます。


はじめに

reglは、シェーダーを自分で書く必要があります。
three.jsのようにBoxGeometryというクラスを使えば簡単に立方体をつくることができたり、
PixiJSのようにGraphicsというクラスを使えば簡単に四角形などをつくることができたり…
はできません。
四角形であろうと、丸であろうと、立方体であろうと、
簡単に図形をつくることができるクラスやメソッドは用意されておらず、
reglで何かをつくりたければ自分自身でシェーダーを書かなければいけません。
(three.jsPixiJSはメジャーなcanvas系のライブラリです)

(ライブラリについての記事はこちら) techblog.kayac.com

では、他のライブラリと比べて嬉しい点はどこかと言うと、

  • 関数型っぽく書ける
  • パッと見、簡単・お手軽そうに見えるので心に優しい

(コードの量が少なく・必要なものを引数で渡すだけ…パッと見、簡単そうに見えませんか?)

regl({
    // バーテックスシェーダー
    vert: `
        attribute vec3 a_position;

        void main() {
            gl_Position = vec4(a_position, 1.0);
        }
    `,
    // フラグメントシェーダー
    frag: `
        precision mediump float;
        
        uniform vec4 u_color;
        
        void main() {
            gl_FragColor = u_color;
        }
    `,
    // attribute修飾子付き変数
    attributes: {
        a_position: [
            [0, 0.5, 0],
            [0.25, -0.25, 0],
            [-0.25, -0.25, 0],
        ],
    },
    // uniform修飾子付き変数
    uniforms: {
        u_color: [1.0, 0.5, 0.0, 1.0],
    },
    // 頂点の数
    count: 3,
})();

とくに、「関数型っぽく書ける」という部分がオススメポイントなのですが…
僕は今でも思っています、「つまりどういうことだ…」と。

正直、僕は簡単そうに見える見た目に惑わされて入門しました。
関数型プログラミング言語もさわったことがありません。

そのため、「reglはこうやって使うと強い!」という方法は紹介できないのですが、
「関数型っぽく書ける」という点は、「強そう!」と思いましたので
reglを知ってもらうために、この記事を書いています。
そして、ハマった方から「reglの使い方を教えてもらおう!」と企んでいます。

準備

まずは、reglを入手しましょう。
この記事で使用しているreglのversionは、1.3.9です。

npmの方はこちら

npm install -D regl

cdnの方はこちら

<script src="//npmcdn.com/regl/dist/regl.min.js"></script>

公式ドキュメント

まずはここから

オレンジ色の三角形をつくってみましょう。

f:id:suzu_kun:20181218094556p:plain

ステップ 1 "canvasの入れ物を用意する"

canvasの入れ物を用意します。
ここの説明はこれだけになります。

<div class="view"></div>
const $view = document.querySelector('.view');

ステップ 2 "createREGLを実行して、reglインスタンスをつくる"

reglを始めるには、 createREGLという関数を実行し、reglインスタンスをつくる必要があります。
このインスタンスに、このあと出てくる描画関数をつくるための関数などが詰まっています。

createREGLは、reglインスタンスを返すだけでなく、
containerプロパティに入れたDOMに、canvasを追加してくれます。

// npmの方だけ
import createREGL from 'regl';

const regl = createREGL({
    container: $view,
    pixelRatio: window.devicePixelRatio,
});

公式ドキュメント

ステップ 3 "reglを実行して、描画関数をつくる"

次は、描画関数をつくりましょう。
描画関数を実行することで初めて、つくりたいもの(今回はオレンジ色の三角形)が画面に描画されます。

描画関数は、regl({...})を実行すると返ってくる、
WebGLの構文が詰まった関数になっています。

// バーテックスシェーダー
const vert = `

attribute vec3 a_position;

void main() {
    gl_Position = vec4(a_position, 1.0);
}

`;

// フラグメントシェーダー
const frag = `

precision mediump float;

uniform vec4 u_color;

void main() {
    gl_FragColor = u_color;
}

`;

// attribute修飾子付き変数
const attributes = {
    a_position: [
        [0, 0.5, 0],
        [0.25, -0.25, 0],
        [-0.25, -0.25, 0],
    ],
};

// uniform修飾子付き変数
const uniforms = {
    u_color: [1.0, 0.5, 0.0, 1.0],
};

// 描画関数をつくる。
const draw = regl({
    vert,
    frag,
    attributes,
    uniforms,
    count: attributes.a_position.length,
});

reglの引数にある、

プロパティ名 概要
vert バーテックスシェーダー
frag フラグメントシェーダー
attributes attribute修飾子付き変数
uniforms uniform修飾子付き変数

については、GLSLを書いたことがある方であれば
説明不要だと思いますので、説明を省略します。
countは、「有効な頂点の数」という認識で大丈夫です。
今回は、
attributes.a_position.length(頂点データの数)を
そのままcountに入れていますので、すべての頂点が描画されます。

公式ドキュメント

ステップ 4 "描画関数を実行して、画面に描画する"

さて、それでは描画しましょう!

draw();

これで完了です。
画面を見てみると…なんとも美しい三角形が描画されていることと思います。

ここまでが、reglの基本的な流れです。

  1. canvasの入れ物を用意する
  2. createREGLを実行して、reglインスタンスをつくる
  3. reglを実行して、描画関数をつくる
  4. 描画関数を実行して、画面に描画する

とてもお手軽な感じがしませんか?
(僕はこの、お手軽な感じに惹かれて、GLSLの勉強方法にreglを選びました)
ここまでくれば、あとは公式のexamplesを見れば「なるほどなるほど」と進んでいけると思います。

おわりに

reglは、(特に日本語の)情報が少ないです。
すごくいいこそうと、僕は思っているのですが、使いこなせていないのが現状です。
そのため、上記でお話しした通りのプランでパワーアップしようと企んでいます(他力本願)。

そしてカヤックでは、一緒に企んでくれる仲間を募集中です。
reglが嫌いでも大丈夫です。社内でもreglから目を背けた人がいます。


ほかのWebGL系の記事はこちら。

techblog.kayac.com techblog.kayac.com techblog.kayac.com