e.blog

主にUnity/UE周りのことについてまとめていきます

Unityで簡易的なドローイングツールを作ってみたので実装についてまとめ

概要

Unityで簡易的なドローイング機能がプロジェクトで必要になったので作ってみたもののまとめです。
実装はシェーダを利用して描いていて、ブラシと色とサイズを変更できるようになっています。

実際の動きはこんな感じ↓

ここで解説している機能のアセットはアセットストアで公開されているのでよかったら購入してください。

assetstore.unity.com

実装方針

実装方針は以下です。

  1. 描画対象となるImage要素上の位置を算出する
  2. 上記位置をImage要素内のUV値に変換する
  3. 該当位置に対して、設定されたブラシを描画する
  4. ブラシを描画する際は、さらにブラシ用のUV値を求める
  5. 描画ごとにバッファを差し替えて連続して描画する(ダブルバッファ)

という流れで実装しています。
実際のアセットではもう少し細かい調整を行っていますが、メインとなる処理は上記の通りです。

Image要素上の位置を算出する

Image(厳密にはRectTransform)上の位置を計算するには

public static bool ScreenPointToLocalPointInRectangle(RectTransform rect, Vector2 screenPoint, Camera cam, out Vector2 localPoint);

を利用します。

ドキュメント↓

docs.unity3d.com

実装は以下のような感じです。

private bool TryConvertToLocalPosition(Vector3 screenPos, out Vector2 pos)
{
    Camera camera = null;
    if (_drawImage.canvas.renderMode != RenderMode.ScreenSpaceOverlay)
    {
        camera = _camera;
    }

    return RectTransformUtility.ScreenPointToLocalPointInRectangle(_rectTrans, screenPos, camera, out pos);
}

RenderModeの判定を行っていますが、通常のモード(ScreenSpaceOverlay)の場合はCameraは関係ないのでnullを渡す必要がある点に注意してください。

この関数は、対象となる位置ベクトルがRectTrasnform内にある場合はtrueを返します。そして引数の最後で、ローカルの位置を格納して戻してくれます。
この位置ベクトルを次のUV値計算に利用します。

ちなみに、位置は関係なく、RectTrasnform内に入っているか、だけを判定する関数もあるので、入っているか否かだけを判断する場合はこちらの関数を利用するといいでしょう。

public static bool RectangleContainsScreenPoint(RectTransform rect, Vector2 screenPoint);
    // or
public static bool ScreenPointToLocalPointInRectangle(RectTransform rect, Vector2 screenPoint, Camera cam, out Vector2 localPoint);

要素上の位置をUV値に変換する

要素上の位置が求まったら、それをUV値に変換します。
といっても大した計算はしません。対象の要素(Image)のサイズでそれぞれ正規化してやればOKです。

// WidthとHeightはRectTransformのサイズ
// private float Width => _rectTrans.rect.width;
// private float Height => _rectTrans.rect.height;

private Vector4 NormalizePosition(Vector2 pos)
{
    float x = (pos.x + Width * 0.5f) / Width;
    float y = (pos.y + Height * 0.5f) / Height;
    return new Vector4(x, y, 0, 0);
}

注意点としては、RectTransform内の位置は中心を0として、プラスマイナスの値で返ってくるため、0 ~ 1の間になるように調整する必要があります。

ブラシのUVを算出する

位置が求められたので、次はブラシを描く位置とサイズを調整します。

と言ってもやっていることはむずかしくありません。
ひとつ問題なのは、描こうとしている対象のRenderTextureのUV値をそのまま利用することができません。
そのまま利用してしまうと、ただ単に、ブラシテクスチャをRenderTextureの範囲いっぱいに描いてしまうことになるからです。

なのでブラシ用に新しくUV値を計算する必要があるわけです。
といっても計算自体はそこまでむずかしくありません。

図にすると以下のようなイメージです。

f:id:edo_m18:20191110144113p:plain

今回の実装では以下のようにしました。

// 描きたい位置にまずオフセットする
float2 buv = i.uv - _Pos.xy;

// 次に、ブラシサイズになるようにUV空間をスケールする
buv *= _BrashSize;

// RenderTextureのアスペクト比を掛けて比率を調整する
buv.x *= _Aspect;

// 最後に、ブラシが位置の中央に描かれるようにオフセットする
buv += float2(0.5, 0.5);

オフセットのプラスマイナスは注意が必要です。図にすると以下のイメージ。

f:id:edo_m18:20191110144816p:plain

最後の計算が0.5になっているのは、この計算時点でブラシ用UV座標(0 ~ 1の範囲)に変換されているため、中心位置に変換するためには1.0の半分である0.5を足すだけで大丈夫です。

なお、ブラシUV座標に変換するためにブラシサイズを掛けていますが、ブラシサイズはC#側から計算して送っています。
具体的には以下のようにして計算しています。

private float NormalizeBrushSize(float brushSize)
{
    return Height / brushSize;
}

HeightRectTransformの高さですね。前の計算でアスペクト比があっているのでHeightを使っています。
そしてそれをブラシサイズで割った値を利用しています。これは、ブラシサイズになるように空間を「縮小」することで実現しています。

コードを見てみると計算自体は複雑ではありませんね。
ただ、UV座標を色々いじっていると頭の中が混乱することが多いです。
例えば、ブラシサイズを「小さく」しようとする場合は、スケールを「掛ける」必要があります。

uv *= 2.0; とすると「半分」のサイズになる。

よくよく考えれば当たり前なのですが、計算としては逆なのでたまに勘違いして計算を逆にしてしまうことがよくあります。

UVの計算については以前のこの記事を書いたときに、色々試していてだいぶ慣れてきました。

edom18.hateblo.jp

色を混ぜる

最後に、描こうとしている対象の色とブラシの色を混ぜ合わせて最終結果を作ります。
最初はたんに掛けたり足したりしていたのですが、それだけだと望んだ結果になりません。

最終的には、いわゆるアルファブレンディングと同じ方法でブレンドすることで解決しました。
コードは以下のような感じ。

fixed4 brash = tex2D(_BrashTex, buv);

// ブラシの黒いところに色をつけたいので反転させる
brash.rgb = 1.0 - brash.rgb;

// Blend SrcAlpha OneMinusSrcAlphaな感じでブレンドした色を返す
return lerp(col, (brash * _Color), brash.a);

このアセットに興味が出たらぜひ購入をお願いします!

assetstore.unity.com