はなちるのマイノート

Unityをメインとした技術ブログ。自分らしくまったりやっていきたいと思いますー!

【Unity】PUN2でみんなでお絵かきできるRawImageを作ってみる

はじめに

今回はPhoton Unity Networking 2 (PUN2)を使った複数人で共有してお絵描きできるキャンバスを作ってみたいと思います。

f:id:hanaaaaaachiru:20210312175338g:plain
動作の様子

準備

まずはPUN2のセットアップが必要になります。以下の公式ドキュメントにあるPUNのインポートと設定を済ませてください。

doc.photonengine.com


ルームに自動で入るように

今回は公式ドキュメントの基本チュートリアルにそいながら、シーン起動時に適当なルームに入るようコーディングしました。

細かい設定は各自でドキュメントを参考にしながら好きにしてみてください。

namespace NetWork
{
    [DefaultExecutionOrder(-100)]
    public class Launcher : MonoBehaviourPunCallbacks
    {
        public bool IsConnected { get; private set; } = false;

        private const byte MAX_PLAYERS_PER_ROOM = 4;

        // 同一マッチがするバージョン
        private string _gameVersion = "1";
        
        private void Awake()
        {
            IsConnected = false;
            PhotonNetwork.AutomaticallySyncScene = true;
        }

        private void Start()
        {
            Connect();
        }

        public void Connect()
        {
            if (PhotonNetwork.IsConnected)
            {
                PhotonNetwork.JoinRandomRoom();
            }
            else
            {
                PhotonNetwork.GameVersion = _gameVersion;
                IsConnected = PhotonNetwork.ConnectUsingSettings();
            }
        }

        public override void OnConnectedToMaster()
        {
            if (IsConnected)
            {
                PhotonNetwork.JoinRandomRoom();
                Debug.Log("PUN Basics Tutorial/Launcher: OnConnectedToMaster() was called by PUN");
                IsConnected = false;
            }
        }
        
        public override void OnDisconnected(DisconnectCause cause)
        {
            Debug.LogWarningFormat("PUN Basics Tutorial/Launcher: OnDisconnected() was called by PUN with reason {0}", cause);
        }
        
        public override void OnJoinRandomFailed(short returnCode, string message)
        {
            Debug.Log("PUN Basics Tutorial/Launcher:OnJoinRandomFailed() was called by PUN. No random room available, so we create one.\nCalling: PhotonNetwork.CreateRoom");

            PhotonNetwork.CreateRoom(null, new RoomOptions{ MaxPlayers =  MAX_PLAYERS_PER_ROOM });
        }

        public override void OnJoinedRoom()
        {
            Debug.Log("PUN Basics Tutorial/Launcher: OnJoinedRoom() called by PUN. Now this client is in a room.");
            
            IsConnected = true;
        }
    }
}

線を描画するアルゴリズム

お絵描きをするスクリプトを書く前に,線を描画するスクリプトを書きたいと思います。というのもフレームレートの性質上,素早くペンを移動させると破線のようになってしまうので、それを防ぐために必須です。

ネット上で調べたところVector2.Lerpを使う方法ブレゼンハムの線分描画アルゴリズムを使う方法あたりが有用そうでした。

https://virtualcast.jp/blog/2019/12/whiteboard/
ブレゼンハムのアルゴリズム - Wikipedia

もしかしたら他に良い方法もあるかもしれませんし、とりあえず抽象クラスでインターフェイス(API)を規定しておきます。

namespace Drawer
{
    public abstract class LineDrawer
    {
        /// <summary>
        /// 線に含まれるピクセルの座標を返す
        /// </summary>
        /// <param name="start">開始座標</param>
        /// <param name="end">終了座標</param>
        /// <returns>描画点の座標</returns>
        public abstract IEnumerable<Point> Draw(Point start, Point end);
    }

    public readonly struct Point : IEquatable<Point>
    {
        public short X { get; }
        public short Y { get; }

        public Point(short x, short y)
        {
            X = x;
            Y = y;
        }

        public Vector2 ToVector2()
            => new Vector2(X, Y);

        public static float Distance(Point a, Point b)
            => Mathf.Sqrt((a.X - b.X) * (a.X - b.X) + (a.Y - b.Y) * (a.Y - b.Y));

        public static Point Lerp(Point a, Point b, float t)
        {
            var vec = Vector2.Lerp(a.ToVector2(), b.ToVector2(), t);
            return new Point((short)vec.x, (short)vec.y);
        }

        public override int GetHashCode()
            => X ^ Y;

        public override bool Equals(object obj)
            => obj is Point && Equals(obj);

        public bool Equals(Point other)
            => X == other.X && Y == other.Y;

        public static bool operator ==(Point self, Point other)
            => self.Equals(other);

        public static bool operator !=(Point self, Point other)
            => !(self == other);
    }
}

わざわざPointという構造体を作成しましたが,まず前提としてPhotonを使用する場合大きなデータを転送することは推奨されません。

Vector2の要素がfloatであり,5byte * 2消費する必要がありますが、shortにすれば3byte * 2にまで抑えることができます。
Photonでの直列化 | Photon Engine

shortの範囲は-32,768 ~ 32,767ですが,キャンバス上の座標としては十分すぎる値です。

ですので少しでもデータを削減できるようVector2ではなくPointをその代わりに使っていきたいと思います。

Vector2.Lerpを使う方法

こちらの記事を参考にさせてもらいながらコードを作成しました。
https://virtualcast.jp/blog/2019/12/whiteboard/

namespace Drawer
{
    public class LerpLineDrawer : LineDrawer
    {
        public InterPointsDistance InterPointsDistance = new InterPointsDistance(5);
        
        public override IEnumerable<Point> Draw(Point start, Point end)
        {
            if (start == end)
            {
                yield return start;
                yield break;
            }

            var lineLength = Point.Distance(start, end);
            var lerpCount = Mathf.CeilToInt(lineLength / InterPointsDistance.Value);
            
            for(var i = 1; i <= lerpCount; i++)
            {
                var lerpWeight = (float) i / lerpCount;
                var lerpPosition = Point.Lerp(start, end, lerpWeight);
                yield return new Point(lerpPosition.X, lerpPosition.Y);
            }  
        }
    }

    /// <summary>
    /// 描画する点の距離の感覚
    /// </summary>
    public readonly struct InterPointsDistance
    {
        public int Value { get; }

        public InterPointsDistance(int value)
        {
            if (value <= 0) throw new ArgumentException("valueは0以下にできません。");

            Value = value;
        }
    }
}

こちらの方法だとピクセル単位での描画ができず,雑な近似での描画しかできないのは難点です。というのもVector2がそもそもfloatを想定して作られているからですね。

しかしお絵描きにそんな厳密性を求める必要はない場合が多いし、ペンは幅があることがほとんどだと思うので、実用としても耐えうると思います。

一応テストもしておきました。

namespace Drawer.Tests
{
    public class LerpLineDrawerTest
    {
        [TestCaseSource(nameof(LineTestCases))]
        public void LerpLineDrawerTestSimplePasses(Point start, Point end, Point[] expectedPoints)
        {
            LerpLineDrawer lineDrawer = new LerpLineDrawer();
            lineDrawer.InterPointsDistance = new InterPointsDistance(1);
            
            int index = 0;

            foreach (var point in lineDrawer.Draw(start, end))
                Assert.AreEqual(point, expectedPoints[index++]);
        }
        
        private static IEnumerable<TestCaseData> LineTestCases
        {
            get
            {
                yield return new TestCaseData(new Point(0, 0), new Point(4, 4), new [] {new Point(0, 0), new Point(1, 1), new Point(2, 2), new Point(2, 2), new Point(3, 3), new Point(4, 4)});
                yield return new TestCaseData(new Point(0, 0), new Point(0, 4), new [] {new Point(0, 1), new Point(0, 2), new Point(0, 3), new Point(0, 4)});
                yield return new TestCaseData(new Point(0, 0), new Point(4, 0), new [] {new Point(1, 0), new Point(2, 0), new Point(3, 0), new Point(4, 0)});
                yield return new TestCaseData(new Point(0, 4), new Point(4, 0), new [] {new Point(0, 3), new Point(1, 2), new Point(2, 2), new Point(2, 1), new Point(3, 0), new Point(4, 0)});
                yield return new TestCaseData(new Point(0, 0), new Point(0, 0), new [] {new Point(0, 0)});
            }
        }
    }
}

ブレゼンハムの線分描画アルゴリズムを使う方法

Wikipediaに載っているコードを参考にしながら以下のコードを作成してみました。
ブレゼンハムのアルゴリズム - Wikipedia

namespace Drawer
{
    public class BresenhamsLineAlgorithm : LineDrawer
    {
        public override IEnumerable<Point> Draw(Point start, Point end)
        {
            int dx = Mathf.Abs(end.X - start.X);
            int dy = Mathf.Abs(end.Y - start.Y);
            short sx = (short) (start.X < end.X ? 1 : -1);
            short sy = (short) (start.Y < end.Y ? 1 : -1);
            int err = dx - dy;

            var x = start.X;
            var y = start.Y;

            while (true)
            {
                yield return new Point(x, y);
                if (x == end.X && y == end.Y) break;
                var e2 = 2 * err;
                if (e2 > -dy)
                {
                    err = err - dy;
                    x = (short) (x + sx);
                }

                if (e2 < dx)
                {
                    err = err + dx;
                    y = (short) (y + sy);
                }
            }
        }
    }
}

一応テストもしておきましょう。

namespace Drawer.Tests
{
    public class BresenhamsLineAlgorithmTest
    {
        [TestCaseSource(nameof(LineTestCases))]
        public void BresenhamsLineAlgorithmTestSimplePasses(Point start, Point end, Point[] expectedPoints)
        {
            LineDrawer lineDrawer = new BresenhamsLineAlgorithm();

            int index = 0;

            foreach (var point in lineDrawer.Draw(start, end))
                Assert.AreEqual(point, expectedPoints[index++]);
        }

        private static IEnumerable<TestCaseData> LineTestCases
        {
            get
            {
                yield return new TestCaseData(new Point(0, 0), new Point(4, 4), new [] {new Point(0, 0), new Point(1, 1), new Point(2, 2), new Point(3, 3), new Point(4, 4)});
                yield return new TestCaseData(new Point(0, 0), new Point(0, 4), new [] {new Point(0, 0), new Point(0, 1), new Point(0, 2), new Point(0, 3), new Point(0, 4)});
                yield return new TestCaseData(new Point(0, 0), new Point(4, 0), new [] {new Point(0, 0), new Point(1, 0), new Point(2, 0), new Point(3, 0), new Point(4, 0)});
                yield return new TestCaseData(new Point(0, 4), new Point(4, 0), new [] {new Point(0, 4), new Point(1, 3), new Point(2, 2), new Point(3, 1), new Point(4, 0)});

            }
        }
    }
}

キャンバスを作成する

序章が長すぎて疲れてきてしまったかもしれませんが、ここからメインであるオンラインで同期するキャンバスを作成していきます。

DrawableTexture

まずはTexture2Dのラッパーである,DrawableTextureというクラスを作成し,テクスチャーに容易に線がひけるようにします。

Texture2Dクラスに機能を追加するということで,Texture2Dを継承しても良かったのですがsealedということで委譲しています。

namespace Drawer
{
    public interface IDrawable : IDisposable
    {
        Texture2D Texture { get; }
        void Draw(Line line, Color color);
        void Apply();
    }
    
    public class DrawableTexture : IDrawable
    {
        public Texture2D Texture { get; private set; }

        private LineDrawer _lineDrawer;

        public DrawableTexture(int width, int height, LineDrawer lineDrawer)
        {
            if (lineDrawer == null) throw new ArgumentNullException("lineDrawerはnullにできません。");
            
            Texture = new Texture2D(width, height, TextureFormat.RGBA32, false);
            _lineDrawer = lineDrawer;
        }

        public void Draw(Line line, Color color)
        {
            foreach (var point in _lineDrawer.Draw(line.Start, line.End))
                for (int i = -line.LineWeight; i <= line.LineWeight; i++)
                    for (int j = -line.LineWeight; j <= line.LineWeight; j++)
                        Texture.SetPixel(point.X + i, point.Y + j, color);
        }

        public void Apply()
        {
            Texture.Apply();
        }

        public void Dispose()
        {
            Texture = null;
        }
    }
    
    public struct Line
    {
        public Point Start { get; }
        public Point End { get; }
        
        /// <summary>
        /// ペンの太さ
        /// </summary>
        public short LineWeight { get; }

        public Line(Point start, Point end, short lineWeight)
        {
            if (lineWeight <= 0) throw new ArgumentException("lineWeightは0以下にできません。");
            
            Start = start;
            End = end;
            LineWeight = lineWeight;
        }
    }
}

CanvasSynchronizer

次にキャンバスを同期させるためにPhotonのクライアントコードを書いていきます。

Photonリモートプロシージャコール(RPC)なるものを活用して、他の端末との同期をとります。
doc.photonengine.com

PunRPC属性のついたメソッドはMonoBehaviourを継承しているクラスでないとダメなようです。

namespace NetWork
{
    [RequireComponent(typeof(PhotonView))]
    public class CanvasSynchronizer : MonoBehaviour
    {
        private Subject<(short, short, short, short, short)> _synchronizedStream;
        public IObservable<(short, short, short, short, short)> OnSynchronizeAsObservable() => _synchronizedStream;

        private PhotonView _photonView;
        
        private void Awake()
        {
            _synchronizedStream = new Subject<(short, short, short, short, short)>();
            _photonView = GetComponent<PhotonView>();
        }

        public void Synchronize(short startX, short startY, short endX, short endY, short lineWeight)
        {
            _photonView.RPC(nameof(SynchronizeLine), RpcTarget.All, startX, startY, endX, endY, lineWeight);
        }
        
        public void Synchronize(short x, short y, short lineWeight)
        {
            _photonView.RPC(nameof(SynchronizePoint), RpcTarget.All, x, y, lineWeight);   
        }

        [PunRPC]
        internal void SynchronizeLine(short startX, short startY, short endX, short endY, short lineWeight)
        {
            _synchronizedStream.OnNext((startX, startY, endX, endY, lineWeight));
        }

        [PunRPC]
        internal void SynchronizePoint(short x, short y, short lineWeight)
        {
            _synchronizedStream.OnNext((x, y, x, y, lineWeight));
        }
    }
}

namespace Drawer
{
    public class CanvasSynchronizerAdapter : CanvasSynchronizer
    {
        public IObservable<Line> OnDrawAsObservable() => OnSynchronizeAsObservable()
            .Select(data => new Line(new Point(data.Item1, data.Item2), new Point(data.Item3, data.Item4), data.Item5));

        public void Synchronize(Line line)
        {
            Synchronize(line.Start.X, line.Start.Y, line.End.X, line.End.Y, line.LineWeight);
        }
        
        public void Synchronize(Point point, short lineWeight)
        {
            Synchronize(point.X, point.Y, lineWeight);
        }
    }
}

一応アセンブリ(Assembly Definition Files)を分割して作成していて、NetWorkアセンブリのみPhotonに依存させたかったので、目にするのは珍しい継承を使ったAdapterパターンで対応。

DrawCanvas

あとは用意してきた部品達を使うクライアントを作成すれば完成です。

namespace Drawer
{
    public class DrawCanvas : MonoBehaviour
    {
        [SerializeField, Tooltip("描画するRawImage")]
        private RawImage _image;

        [SerializeField, Tooltip("Photonと通信するクライアント")] 
        private CanvasSynchronizerAdapter _synchronizer;
            
        public bool IsWriting { get; private set; } = false;
        public short LineWeight { get; private set; } = 5;
        public Color DrawColor { get; private set; } = Color.black;

        private IDrawable _texture;
        private Queue<Line> _drawLines;
        
        private Rect _rect;
        private Point _lineStart;
        
        private void Start()
        {
            _image.OnDragAsObservable()
                .ThrottleFirst(TimeSpan.FromMilliseconds(33))
                .Subscribe(OnDrag)
                .AddTo(this);
            
            _image.OnEndDragAsObservable()
                .Subscribe(OnEndDrag)
                .AddTo(this);

            this.UpdateAsObservable()
                .Subscribe(_ => UpdateTexture())
                .AddTo(this);
            
            _rect = _image.gameObject.GetComponent<RectTransform>().rect;
            _texture = new DrawableTexture((int)_rect.width, (int)_rect.height, new LerpLineDrawer());
            _image.texture = _texture.Texture;
            _drawLines = new Queue<Line>();

            _synchronizer.OnDrawAsObservable()
                .Subscribe(line => _drawLines.Enqueue(line))
                .AddTo(this);
        }

        private void OnDestroy()
        {
            _texture.Dispose();
        }

        private void UpdateTexture()
        {
            while (_drawLines.Count != 0)
            {
                var line = _drawLines.Dequeue();
               _texture.Draw(line, DrawColor);
               _texture.Apply();
            }
        }

        private void OnDrag(PointerEventData data)
        {
            // スクリーンの座標から,RawImageの座標(RawImageの左下原点)に変換する,ここら辺は自信なし
            var x = (short) (data.position.x + _rect.width / 2 - Screen.width / 2);
            var y = (short) (data.position.y + _rect.height / 2 - Screen.height / 2);

            if (x <= 0 || _rect.width <= x || y <= 0 || _rect.height <= y)
            {
                // 描画範囲外に移動
                IsWriting = false;
                return;
            }

            if (!IsWriting)
            {
                // 描き始めなので点を描画する
                IsWriting = true;
                _synchronizer.Synchronize(new Point(x, y), LineWeight);
            }
            else
            {
                // 線を描画する
               _synchronizer.Synchronize(new Line(_lineStart, new Point(x, y), LineWeight));
            }

            _lineStart = new Point(x, y);
        }

        private void OnEndDrag(PointerEventData data)
        {
            IsWriting = false;
        }
    }
}

ユーザーとの入出力インターフェイス周りを書きました。またEventTriggerを使っているので,パソコンでもスマホでも同じコードで座標を取得することができます。

どのタイミングでどれくらいサーバーから情報が送られてくるか分からなかったため、一度QueueにためてからUpdateで処理するようにしました。

QueueにためたことでUndoなんかもしやすいはずですが、パッと考えてみたところ結構実装難易度は高そうです。

またこれまでUniRxを使いまくっていますので、もし使ったことがないかたは是非触ってみると良いかもしれません。

一応UniRxを使っているおかげで,クライアント側の処理が厳しそうならUpdateを間引いたりなどの条件を付け加えたり、Photonに送る情報量が多すぎならOnDragの頻度を減らしたり(とりあえず最短でも33msの間は情報を送らないようにしてみた)などの対応がしやすくなっています。

さいごに

お絵描きできるRawImageを作るというお題でしたが,ベタ書きじゃなく設計について考慮しながらのコードになってしまったので長くなってしまって申し訳ないです。

細かい箇所を放っておいても,みなさん大まかな流れは似た感じになると思うので良かったら参考にしてみてください。

ではまた。