遊んで航海記

思いつきで遊んだり、ゲームを作ったり、寝たり

Unicessingのある夏休み:心の声を描いてみよう!

この記事は「Unity アセット真夏のアドベントカレンダー 2017」23日目の記事です。

前日の記事はhisaitamiさんの「SciFiインターフェイスを作るのに便利なアセット」でした。SFっぽいインターフェイスを手軽に使えるのはありがたいですね。「意味は分からないけど重要そうなデータを可視化したイメージ」に笑ってしまいました :D

さて、今日は「Unicessingのある夏休み」ということで、図形を描いたりするのに便利なUnity Asset、Unicessingを使ってみようと思います。

そもそもUnicessingとは? 何ができるの?

Unicessingそのものについてはこのブログ内でいくつか記事にしてあったり…

eyln.hatenablog.com

TwitterのモーメントでUnicessingユーザーの方々の作例をまとめてあったりします。

twitter.com

上記モーメントにもありますが、きゅーこんさんのこちらの素敵なサンプルも必見ですGitHubソースコードも公開されてます! 最高です!!

夏休みの心の声を表現…!?

Unicessingは図形を描いていろんな表現を試作するのを得意とするアセットなのですが、今回は文字を描くこと、いや、夏休みの心の声を描くことをテーマにして、ひとつサンプルを作ってみました。名付けて UnicessingMind です!

f:id:n_ryota:20170822172053p:plain

Oculus Riftで見ると、こんな感じ。VRデバイスが使えない環境のときはマウス操作で視界が動かせます。

単語のいろんな組み合わせを発見できるので、アイデア発想ツールみたいな展開もできそうです。

なんで心の声?

実はいま「メドゥーサと恋人」というVRゲームを開発中で、メドゥーサは(ボタンで)目をつむれるのですが、目をつむったときに心象風景を出せたら表現の幅が広がりそう、とか思って――その実験もかねて書いてみました。文字でやるか、オブジェクトでやるか、あるいはそんなことはしないか、まだ未定ですけど。

www.youtube.com

これは、メドゥーサと恋人のプロトタイプバージョンの操作説明動画です。でも心象風景の実装とかはアイデアや試験実装の段階なので、上記動画には入ってません。

UnicessingMindのパッケージとソース

Unityパッケージはこちらからダウンロードできます。別途、UnicessingPostProcessingStackが必要です。

サンプルのソースはこちら。

// Unicessing Mind サンプル
// Written by NISHIDA Ryota, http://dev.eyln.com

using UnityEngine;
using Unicessing;

// 心象を表現したテキストを空間に浮かべます
public class UnicessingMind : UGraphics
{
    public string textFileName = "words.txt";   // 読み込むアセットファイル名(UTF8)

    // 初期化
    protected override void Setup()
    {
        rotateDegrees();                 // 回転の単位をラジアンではなく「度」にする
        colorMode(ColorMode.RGB, 1.0f);  // 色の範囲をRGBA各0.0f~1.0fにする
        blendMode(ADD);                  // 加算描画にする

        // テキストファイルを読み込んで単語を配置
        loadStringText(textFileName, text => { CreateWords(text); });
    }

    // テキストを1行ごとにランダムに空間配置
    void CreateWords(string text)
    {
        string[] words = split(text, '\n');
        var center = Camera.main.transform.position;
        foreach (string s in words)
        {
            var v = randomVec3();
            var r = random(20.0f, 30.0f);
            v.x *= r;
            v.y *= r * 0.5f;
            v.z *= r;
            AddWord(s, center + v);
        }
    }

    // 指定位置に単語を追加
    void AddWord(string word, Vector3 pos)
    {
        // UnicessingWordコンポーネントを持つGameObjectを作成
        var uniWord = createSubGraphics<UnicessingWord>("UniWord " + word);
        uniWord.word = word;
        uniWord.transform.position = pos;
    }

    // 毎フレームの更新と描画
    protected override void Draw()
    {
        // 非VRのときはマウスでカメラを回転
        if(!isVR)
        {
            float x = (float)Input.mousePosition.x / Screen.width - 0.5f;
            float y = (float)Input.mousePosition.y / Screen.height - 0.5f;
            var camTrans = Camera.main.transform;
            float yLevel = camTrans.forward.y;
            if ((yLevel < -0.8f && y < 0) || yLevel > 0.8f && y > 0) y = 0.0f;  // 極端に床や天井を向けないようにする
            const float speed = 2.0f;
            camTrans.Rotate(-y * speed, x * speed, 0);             // カメラ回転
            camTrans.LookAt(camTrans.position + camTrans.forward); // 姿勢を正す
        }

        // 下に波紋を描画
        translate(0.0f, -3.0f, 0.0f);
        rotateX(90.0f);
        noFill();
        for (int i = 0; i < 5; i++)
        {
            float a = modulo(i / 5.0f - frameSec * 0.05f, 1.0f);
            a *= a; a *= a;         // 外側にいくほどゆっくり動くように
            stroke(1.0f, a);
            float r = (1 - a) * 10.0f;
            ellipse(0, 0, r, r);    // 円を描く
        }
    }

    // Unicessingを使った単語描画クラス
    class UnicessingWord : USubGraphics
    {
        public string word = "";    // 表示する単語
        float fontSize = 1.0f;      // フォントの大きさ
        SphereCollider collider;    // カメラの視線コライダーとの衝突判定用コライダー
        bool isHit = false;         // 上記が衝突しているかどうか
        float alpha = 0.0f;         // 衝突している間1.0fに近づくα値
        int seed;                   // この単語の乱数種
        float wordW = 0.0f;         // 文字幅

        // 初期化
        protected override void Setup()
        {
            // カメラの視線コライダーとの衝突判定用コライダーを作成
            collider = gameObject.AddComponent<SphereCollider>();
            collider.center = Vector3.zero;
            collider.radius = fontSize;
            collider.isTrigger = true;

            // 衝突判定がとれるようにRigidbodyも追加
            var rigidBody = gameObject.AddComponent<Rigidbody>();
            rigidBody.useGravity = false;
            rigidBody.isKinematic = true;
            seed = g.random(1000);

            // このゲームオブジェクトをカメラの方に向ける
            transform.LookAt(Camera.main.transform);
            transform.Rotate(0, 180, 0);

            // テキスト・フォント指定
            g.textAlign(UGraphics.CENTER, UGraphics.CENTER);
            g.textFont("Times New Roman");
            //g.textFont("BoldMeiryo");
            g.textSize(fontSize);
        }

        // 描画
        protected override void Draw()
        {
            // カメラの視線コライダーがこの単語に当たってる間(見られているとき)は
            // alphaを1に近づけ、当たってなければ0に近づける
            if (isHit) alpha = g.lerp(alpha, 1.0f, 0.04f);
            else alpha *= 0.99f;

            // 円で単語の中央位置を示す(ただし、見られてるときは透明)
            g.translate(collider.center);
            g.stroke(1.0f, 1.0f - alpha);
            g.noFill();
            g.ellipse(0, 0, collider.radius * 2, collider.radius * 2);

            if (alpha < 0.05f) return; // 見られてないときは文字描画しない

            // 一文字ずつ描画(見られてないときは散らばる)
            g.fill(1.0f, alpha);
            g.translate(wordW * -0.5f + fontSize * 0.5f, 0);
            g.randomSeed(seed);
            Vector3 pos = Vector3.zero;
            const float r = 20.0f;
            for (int i = 0; i < word.Length; i++)
            {
                char c = word[i];
                pos = Vector3.Lerp( g.randomScaleVec3(g.random(-r, r) ), pos, alpha);
                g.text(c.ToString(), pos.x, pos.y, pos.z);   // 文字を描く
                pos.x += isHankaku(c) ? fontSize * 0.6f : fontSize;
            }
            wordW = pos.x;
        }

        // 文字が半角か?
        bool isHankaku(char c)
        {
            return (c >= 0x0 && c < 0x81) || (c == 0xf8f0) || (c >= 0xff61 && c < 0xffa0) || (c >= 0xf8f1 && c < 0xf8f4);
        }

        // Colliderのトリガーに触れたとき
        void OnTriggerEnter(Collider other)
        {
            if (other.gameObject != Camera.main.gameObject) return;
            isHit = true;
        }

        // Colliderのトリガーに触れるのをやめたとき
        void OnTriggerExit(Collider other)
        {
            if (other.gameObject != Camera.main.gameObject) return;
            isHit = false;
        }
    }
}

テキストファイルの読み込み

最初にSetup()でテキストファイル"words.txt"を読み込み、読み込み完了後にCreateWords()関数にテキストを渡しています。

        // テキストファイルを読み込んで単語を配置
        loadStringText(textFileName, text => { CreateWords(text); });

ファイルの読み込みについて、Resourcesに置いてあるアセットファイルなら遅延なく読み込み完了するので、下記のように書いても大丈夫です。

        // テキストファイルを読み込んで単語を配置
        string text = loadStringText(textFileName);
        CreateWords(text);

ただローカルフォルダのファイルやネット上のファイルの場合は非同期読み込みとなり、読み込みに時間がかかるので、最初に書いたような書き方をする必要があります。あ、そうそう。ファイル名に"http://~"とか書くとネット上のファイルも読み込めるんです。loadImage()でテクスチャ読み込んだりもできます。

テキストを分割

CreateWords()では最初にsplit()を使って、stringテキストを複数のstringの配列に分割しています。テキストファイルは単語を1行ごとに改行したファイルを用意したので、分割も'\n'の改行コードを区切りに指定しています。*1

        string[] words = split(text, '\n');

単語描画クラスを作成

一単語ずつAddWord()関数で作成していきます。

    // 指定位置に単語を追加
    void AddWord(string word, Vector3 pos)
    {
        // UnicessingWordコンポーネントを持つGameObjectを作成
        var uniWord = createSubGraphics<UnicessingWord>("UniWord " + word);
        uniWord.word = word;
        uniWord.transform.position = pos;
    }

単語を描画するUnicessingWordクラスはUSubGraphicsを継承したクラスです。

createSubGraphics()はGameObjectを生成してUnicessingWordクラスをAddComponent()しつつ、USubGraphicsのメンバー変数 g にUGraphicsを渡します。つまり、複数のGameObjectで同じUGraphicsを使いまわしたいときに使うのがUSubGraphicsなのです。

もちろん、USubGraphicsを使わずにすべて別のUGraphicsで作ることもできますが、ちょっと多いかな、無駄かなと思ったらUSubGraphicsを使うとよいわけですね。

単語の描画

UnicessingWordクラスはUSubGraphicsを継承しているので、UGraphicsと似た感じで、Setup()で初期化、Draw()が毎フレーム呼ばれます。ただUGraphicsと違って、Unicessingの関数を使うときに g.~ といった書き方をする必要があります。

たとえばこんな感じですね。

class UnicessingWord : USubGraphics
{
        ~中略~

        protected override void Draw()
        {
            ~中略~

            // 円で単語の中央位置を示す(ただし、見られてるときは透明)
            g.translate(collider.center);
            g.stroke(1.0f, 1.0f - alpha);
            g.noFill();
            g.ellipse(0, 0, collider.radius * 2, collider.radius * 2);

text()を使えば複数文字を一度に描画できるのですが、今回は1文字ずつ別々に動かしたいので、個別に描画しています。

            Vector3 pos = Vector3.zero;
            const float r = 20.0f;
            for (int i = 0; i < word.Length; i++)
            {
                char c = word[i];
                pos = Vector3.Lerp( g.randomScaleVec3(g.random(-r, r) ), pos, alpha);
                g.text(c.ToString(), pos.x, pos.y, pos.z);   // 文字を描く
                pos.x += isHankaku(c) ? fontSize * 0.6f : fontSize;
            }

見てないときは文字がバラバラになるようランダムな座標をg.randomScaleVec3(g.random(-r, r) )という形で計算し、見ているときは一文字ずつ横にずらしながら文字を配置しています。*2

見ているときと、見ていないときの変化具合はalphaに入っているので、Vector3.Lerp()で座標をなめらかに補間しています。

よく見るとVector3.Leap()後の値が一時変数ではなくposに代入されていますね。こうすると補間中、文字が少し奇妙な動きするんです。バグだったのですが、修正したものより元の方が面白かったので、これを採用しました :D

今回は単語のGameObjectそのものをカメラに向けて、「それで十分見えるのでよし」としています。もし、文字をひと文字ずつカメラに向けたいときは、冗長ですが、下のように書けばOK。

                g.pushMatrix();
                g.translate(pos);
                g.lookAtCamera();
                g.text(c.ToString());   // 文字を描く
                g.popMatrix();

なお、text()によるテキストの描画は毎回メッシュ生成していて、line()やrect()などの通常の図形よりも数段重たいので、高速化したい場合はtextObj = createText()で先に作って、draw(textObj)する形にするとよいかもしれません。*3

あと文字をパーティクル的たくさん出したいときは、オノッチさんのCharacterParticleがおすすめです!*4

単語が見られているかどうかの判定

今回単語を見ているかどうかの判定を、カメラと単語にColliderをつけて、それらが当たっているかどうかで処理を分けています。

次のようにCameraにCapsuleColiderを追加しています。視線方向に長い棒を。

f:id:n_ryota:20170822041815p:plain

単語側の衝突判定用のSphereColliderやRigidBodyは、UnicessingWordクラスのSetup()でAddCompornent()しています。

で、Colliderの衝突判定はレイヤーで分けたいところですが、今回はunitypackageのインポートだけで動くようにしたかったので、カメラオブジェクトのColliderとだけ判定するような処理をOnTrigger側に書いています。

        // Colliderのトリガーに触れたとき
        void OnTriggerEnter(Collider other)
        {
            if (other.gameObject != Camera.main.gameObject) return;
            isHit = true;
        }

        // Colliderのトリガーに触れるのをやめたとき
        void OnTriggerExit(Collider other)
        {
            if (other.gameObject != Camera.main.gameObject) return;
            isHit = false;
        }

実際のところ、Colliderも使わず、USubGraphicsも使わず、UGraphicsのSetup()とDraw()だけでカメラベクトルとの角度などでいっきに判定して描いてしまうほうがプログラムも短くすみそう……なのに、あえて今回このような書き方をしたのは、Unityのほかのアセットや処理との連携を考えている方などに向けて、こんな書き方もあるよ~、という紹介の意図もあってです。

ちょっと応用編のような感じになりましたが、参考になると幸いです

Unicessingと一緒に使って遊びたいアセットリスト

おまけとして、「Unicessingと一緒に使って遊びたいアセットリスト」も作ってみました。

※使ったことないアセットも含みます。

いろいろ試したり、組み合わせて遊んでみたい――ああ、夏休みがもっとほしい!!


次回「Unity アセット真夏のアドベントカレンダー 2017」24日目の記事は、おばたさんの「夏コミゲームで使ったアセットたち」です。実践投入されたアセット、楽しみですね!

*1:ちなみに、複数の区切り文字で改行したいときはsplitTokens()という命令もあります。※ただsplitTokens()のデフォルト値については間違っていたので、もしデフォルトで使う場合は第二引数のdelimを¥ではなくバックスラッシュに変更してください(次に更新予定のVer.0.20では修正します)。

*2:文字のずらし幅は厳密じゃなくて、半角全角でざっくり判定していますが、まぁ、やらないよりはマシということで。

*3:文字描画系はとりあえず出た方が便利でしょ、というレベルで旧コードを移植しただけなので、本格的に使うならもうちょっとマシにしたいところです。

*4:今回のサンプルのような作りだったら、単語位置を注視したらCharacterParticle発生! みたいな作りで実装するのも良さそうです。あれ? Unicessingで苦手な文字を出す意味って…。まぁ、フォント画像を生成しなくていいのがちょっと楽とか、パーティクルシステムではなくプログラムで描画を制御して試作したいときに楽、とかですかね。それと、実は最初こんなサンプルではなくて、本の文章っぽい別サンプル作ってて、いろいろやってたら複雑になってきたので方針を変えたというわけ。ま、いいか!