LoginSignup
1

More than 1 year has passed since last update.

Unity WebGL で <canvas> で文字を描画する

Last updated at Posted at 2022-11-30

本記事は Advent Calendar 2022 Unity #2 の 1日目の記事です。

【1日】 本記事
【2日】【Unity】スマホ端末の傾き角を知る方法【決定版】【加速度センサー】【ジャイロスコープ】 ( @Cova8bitdot )
【3日】 UnityでARスタンプラリーアプリを作ってみた ( @SanQ

TextMeshProの地味に嫌な問題

Unityで文字を描画するとなると今は一部を除いてほぼ間違いなくTextMeshPro(以降TMP)を使って描画すると思います。
TMPは素晴らしいのですが、テキストを描画するに当たりフォントが必要でそのフォントはフォントアトラスといういわゆる画像データに変換して使用することになります。
フォントアトラスは、使用する文字だけを使って生成することができるのである程度容量を抑えることができますが、複数のフォントを使用するとなった場合はフォントの種類分、更に多言語対応となった場合は言語に合わせたフォント分、そして更にチャットなどユーザーが入力した文字列を描画するとなるとユーザーが入力するであろう文字をほぼすべてを網羅した文字を入れたフォントアトラスを生成しなければなりません。
そうなると、フォントアトラスだけで場合によっては数100MBになる可能性もあり、特にWebGLになるとコンパイル結果のサイズがその分膨れてしまい、ともすればランタイム時にローディングだけで数分~数十分かかってしまう結果になってしまいます。
(もちろんアセットバンドル化すればユーザーの言語にあわせて取捨選択してロードすることは可能ですが…)

サンプルプロジェクト

サンプルのプロジェクトを GitHub にあげています。
(プライベートのままとなっていてアクセスできない場合はコメントや私のTwitter宛にお知らせください。すぐにパブリックに切り替えます。)

<canvas> で文字を描くという戦略

WebGLの実行環境はブラウザーであるため、ブラウザーの資産を利用することができます。
そこで、文字(文字列)を<canvas>で描画し、その結果を画像としてUnityに渡しUnity側はそれをテクスチャーとして表示すれば、フォントアトラスを用意しなくても文字を描画することが可能となります。
更に、(CSSでフォントを用意すれば) 多言語においても問題なく描画することができます。

<canvas> で文字を描く

本題に移ります。
<canvas> で1行の文字列を描画するのは比較的簡単です。

// 座標 (10, 10) の位置に赤で塗りつぶされた "Hello world" を描画。
const ctx = canvas.getContext('2d');
ctx.font = '20px serif';
ctx.fillStyle = 'red';
ctx.fillText('Hello world', 10, 10);

ただし複数行の場合は<canvas>の描画APIには自動改行する機能が無いため自分で文字列を改行位置で分割し改行位置を計算して1行1行描画しなければなりません。
更には、単語途中での改行禁止や句読点の行頭禁止などの機能も皆無であるためそこまで考慮して描画しなければならない場合、難易度が爆上がりします。
ですので、テクスチャーの幅に合わせたテキストを表示する要素を別途用意しその要素にいったん文字列を設定します。
これで文字列の描画に関するいろんなめんどいところをブラウザー側で行ってもらいます。
テキストを表示する要素を便宜上 charPositioner という名前にします。
文字列の設定は1文字ごとに <span> 変換して設定します。
これにより、各 <span> の BoundingClientRect を取得することで、1文字単位で位置を取得することができるようになります。
各文字の位置を取得したら、取得した位置をもとに <canvas> に描画します。

function createStringImage(
    text,
    fontFamily,
    fontWeight,
    fontSize,
    textColor,
    textureWidth,
    textureHeight
) {
    textureCanvas.width = textureWidth;
    charPositioner.style.maxWidth = `${textureWidth}px`;
    charPositioner.style.font = `${fontWeight} ${fontSize} ${fontFamily}`;

    // 文字配列への変換は [...text] がシンプルなのだが、
    // これだと絵文字が分解されて配列に変換されてしまう
    // [..."👨‍👨‍👧‍👧"] -> ['👨', '‍', '👨', '‍', '👧', '‍', '👧']
    // 絵文字も1文字単位で分割するために正規表現を使う
    const emojiSpliterRex = /\p{RI}\p{RI}|\p{Emoji}(\p{EMod}|\u{FE0F}\u{20E3}?|[\u{E0020}-\u{E007E}]+\u{E007F})?(\u{200D}\p{Emoji}(\p{EMod}|\u{FE0F}\u{20E3}?|[\u{E0020}-\u{E007E}]+\u{E007F})?)*|./gus;
    const chars = text.match(emojiSpliterRex); //.match(rex);
    // textureHeight !== 0 の場合はテクスチャーの高さを固定する
    if (textureHeight !== 0) charPositioner.style.height = `${textureHeight}px`;

    // 1文字単位で <span> に変換して charPositioner に追加する
    const spans = chars.map(c => {
        const span = document.createElement('span');
        span.style.whiteSpace = 'pre-line';
        span.textContent = c;
        charPositioner.appendChild(span);
        return span;
    });

    // 1文字単位で文字の位置を取得する
    const charRects = spans.map(span => {
        const rect = span.getBoundingClientRect();
        return rect;
    });

    // 描画結果のサイズ(高さ)を取得し textureCanvas も同じサイズにする
    const charPositionerRect = charPositioner.getBoundingClientRect();
    textureCanvas.width = charPositionerRect.width;
    textureCanvas.height = charPositionerRect.height;

    // <canvas> のコンテキストを取得し、文字列描画のための各種設定を行う
    const ctx = textureCanvas.getContext('2d');
    ctx.textAlign = 'start';
    ctx.textBaseline = 'top';
    ctx.fillStyle = textColor;
    ctx.font = `${fontWeight} ${fontSize} ${fontFamily}`;

    // 文字位置取得を終えたら 役目が終わったので <span> をクリアする
    spans.forEach(span => span.remove());

    // 取得した1文字単位の位置をもとに文字を描画する
    charRects.forEach((p, i) => {
        ctx.fillText(chars[i], p.left - charPositionerRect.left, p.top - charPositionerRect.top);
    });

    // charPositioner のサイズを Unity に渡す
    return charPositionerRect.width * 10000 + charPositionerRect.height;
}

文字列画像を生成したら、その描画サイズでレンダーテクスチャーを作成し、JavaScript側でレンダーテクスチャーに文字列画像を描画します。

StringTextureLib_DrawStringTexture: function (textureId) {
    console.log(`texutreId: ${textureId}, GL.Textures[textureId]:${GL.textures[textureId]}`);
    GLctx.bindTexture(GLctx.TEXTURE_2D, GL.textures[textureId]);
    GLctx.texParameteri(GLctx.TEXTURE_2D, GLctx.TEXTURE_WRAP_S, GLctx.CLAMP_TO_EDGE);
    GLctx.texParameteri(GLctx.TEXTURE_2D, GLctx.TEXTURE_WRAP_T, GLctx.CLAMP_TO_EDGE);
    GLctx.texParameteri(GLctx.TEXTURE_2D, GLctx.TEXTURE_MIN_FILTER, GLctx.LINEAR);
    GLctx.pixelStorei(GLctx.UNPACK_FLIP_Y_WEBGL, true);
    GLctx.texSubImage2D(GLctx.TEXTURE_2D, 0, 0, 0, GLctx.RGBA, GLctx.UNSIGNED_BYTE, textureCanvas);
    GLctx.pixelStorei(GLctx.UNPACK_FLIP_Y_WEBGL, false);
}

<canvas> で文字を描くという戦略の良い点

  • TMP(フォントアトラス)を使わなくて済む
  • CSS でスタイルを設定することも可能
  • 多言語対応が楽

<canvas> で文字を描くという戦略の悪い点

  • 見てわかるように手抜きをするため、1文字単位で <span> に変換し 1文字ごとに getBoundingClientRect() で位置を取得しているため、パフォーマンス的に難あり。
  • テクスチャーでの文字列表示となるためどうしてもややボケた感じになってしまう。
  • テクスチャーで描画するためメモリ面で難あり。

以上

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
1