はじめに
今回はPhoton Unity Networking 2 (PUN2)
を使った複数人で共有してお絵描きできるキャンバスを作ってみたいと思います。
ルームに自動で入るように
今回は公式ドキュメントの基本チュートリアルにそいながら、シーン起動時に適当なルームに入るようコーディングしました。
細かい設定は各自でドキュメントを参考にしながら好きにしてみてください。
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
を作るというお題でしたが,ベタ書きじゃなく設計について考慮しながらのコードになってしまったので長くなってしまって申し訳ないです。
細かい箇所を放っておいても,みなさん大まかな流れは似た感じになると思うので良かったら参考にしてみてください。
ではまた。