TOP PAGE

Unity用の自作TCP通信プログラムモジュールの使い方と簡単な例

一つのサーバーに対いて、複数のクライアントが接続して、ゲームを進行させる場合、サーバーでは、複数のクライアント情報が必要になる。
そして、一つのクライアントでは相手と通信する機能が必要となる。

以上より、一つのクライアントの通信を管理するクラスとして、TcpCommunicationを作りました。この中に通信用のTcpClientオブジェクトを内包しています。

そして、サーバ側の管理クラスとして、TcpAppServerを作りました。
このクラスではTCP接続を管理するTcpListenerを持ち、 接続許可で得られるTcpClientオブジェクトを内包するTcpCommunicationを生成し、 その接続相手の集合を、List<TcpCommunication> clientlistで管理しています。

それぞれのクラスは、TcpCommunication.csとTcpAppServer.csのファイルに定義しています。
このページでは、 この使い方とその簡単な作品例(ネット共有のホワイト・ボード)を 紹介しています。
(このソース群を含めたUnityパッケージもダウンロードできます。) 上記の例は、サーバー用の作品とクライアント用作品が別々になっている2つの作品例です。

別途に、サーバーまたはクライアントを起動時に選択して始める、サーバーとクライアント機能をまとめた1つの作品例を
こちらのページで紹介しています。 (これは、ドラックによるカメラの回転を、全てのクライアントで行えて、その画面を共有する作品です。)

TcpCommunication.csとTcpAppServer.csの使い方概要

TcpAppServer.csのファイルは、MonoBehaviour継承のTcpAppServerが定義されて、任意のGameObjectにアタッチして使います。
それだけで、起動時に次のようなサーバー起動用のOnGUIのユーザーインターフェイス画面が現れます。

この画面はサーバーとクライアントを兼用する作品の場合です。
(フラグ操作でサーバーとクライアントどちらかの一方の専用作品へ容易に変更できます。)
そしてサーバーに当てるIPアドレスのボタンをクリックするだけで、 画面のポート番号でサーバが起動し、クライアントからの接続を受け入れるスレッドがスタートします。
その後、デフォルトで接続してくるクライアントを許可してクライアントのリストに登録して受信スレッドが起動します。
そして受信処理は、TcpAppServer.onReceiveのデリゲート変数に登録するだけです。
またリスト内のクライアントを指定して、任意のタイミングで下記TcpCommunicationのSendMessageメソッドで送信できます。


TcpCommunication.csのファイルには、TCP接続後の通信を実現するためのクラス群( TcpCommunicationクラス、BinPacketクラス、QueueBuffeクラス)があります。
TcpCommunicationには、SendMessageメソッドで文字列か任意長のバイト列が送信できます。
そのSendMessageメソッドで送る場合に、BinPacketクラスのオブジェクトに入れて送信されます。
なお、受信したパケットはTcpCommunicationのオブジェクト内のQueueBuffeに記憶されます。
よって、クライアント側ではFixedUpdateメソッド内で、QueueBuffer.GetBuff(tcpCommunication.queueBuffer);で受信の有無を調べて 対応プロフラムを書く
ことになります。
以下にTCPの通信ステップに合わせて、サーバとクライアント接続が終わって、送受信できるまでの内容を説明します。

サーバーの接続待機処理

TcpAppServer.csがサーバー側用のMonoBehaviour継承クラスで、サーバー起動メソッドの「public void StartServer()」があり、 IPアドレスのボタンでこれを最初に実行させます。
その場合、TcpAppServerのインスタンス変数の ipAdrs(型:IPAddress)に記憶されるIPアドレスと、 TcpCommunication.tcp_portのポート番号で待ち受けします。
よって、StartServer()の実行前に、これら変数を設定する必要があります。
この実行で、クライアントからの接続を待ち受けるループのスレッドがスタートしますが、その時に、onTcpServerStartのデリゲート関数が実行します。
よって、予め、onTcpServerStart変数にサーバー起動時に行いたい処理をメソッドにして登録します。
そこには、自身のクライアントの接続処理を書くと良いでしょう。
なお、ipAdrsとtcp_portを設定するためのユーザ処理は、追記しなくてもTcpAppServerのOnGUI()で実現しています。(デフォルトの表示例です)
つまり、TcpAppServer.csがアタッチするだけで起動時に、次のようなサーバー起動か、 クライアント起動かのonGUI用操作画面が出ます。無地のPanelなどを用意して、 ネットワークが実行し始めたら、非activeにすると良いでしょう。
このように、サーバーの接続待機プログラムは、コード追加をほとんどすることなしで、実現することができます。
このonGUI用操作画面インターフェースを使ったStartServer()の実行でなく、独自インターフェースのパネルで実行させたい場合は、 TcpAppServer.onGUI_flagをfalseにします。そうすると、この表示が出ない起動になります。
デフォルトのOnGUIパネルは、起動時にサーバとクライアントを選択して実行する作品用です。
サーバー専用アプリを作成する場合は、TcpAppServer.onGUI_flagClientをfalseにします。
クライアント専用アプリを作成する場合は、TcpAppServer.onGUI_flagServerをfalseにします。
つまり、onGUI用操作画面の画面は、TcpAppServer.onGUI_flag=false;で全て消えます。
また、また、OnGUI部品を並べ始める縦の基準座標変数のonGUI_startY、 OnGUI部品を並べ始める横の基準座標変数のonGUI_startX 、その他フォントサイズなど 調整用変数があるので、後述サンプルのソースコメントを参考に変更が可能です。

クライアントの接続処理

TcpCommunicationクラスに、非同期のインスタンスメソッドでConnectTo()を作っています。
この実行で、TcpCommunication.target_IP の接続先IPアドレス、TcpCommunication. tcp_portのポート番号のサーバに接続し、受信スレッドが起動します。
サーバーとクライアントの兼用アプリケーションをデフォルトのonGUI用操作画面を利用して作る場合、 TcpAppServerのonTcpServerStart変数に、自身のクライアントの接続処理を書きます。
それにより、サーバー起動ボタン(自身のIPアドレスのボタン)をクリックすることで、 クライアントとして自身のサーバーに接続することになります。
接続完了時に実行するTcpAppServerのonTcpServerStart変数にonGUI用操作画面を消す命令を書きます。
それは、次のようなコードです。
  1. まず、MonoBehaviourを継承したメインクラスのインスタンス変数として、以下を用意します。
    TcpCommunication tcpCommunication = new TcpCommunication(); // クライアント用

  2. サーバ起動が起動したらtcpCommunication のクライアントを接続します。
    それは、Startメソッド内で、接続の処理を登録しますが、サーバー兼クライアント作品の場合と、個別に作る場合で異なります。
    サーバー兼クライアントの作品であれば、Startメソッドなどで次のようにサーバー起動時に実行させます。
           TcpAppServer.onTcpServerStart += (server) =>
           {// サーバ起動時の処理
               // tcpCommunication のstatic メンバtarget_IP, tcp_portが示すサーバーに接続
               tcpCommunication.ConnectTo();
           };
    個別のクライアントの作品で、TcpAppServerの接続ボタンを利用して接続の場合は、Startメソッドなどで次のように実行させます。
           TcpCommunication.tcpClientButton += () => {// TcpAppServerの接続用GUIボタンで実行
                tcpCommunication.ConnectTo();
            };
    この接続先は、TcpCommunication.target_IP,TcpCommunication.tcp_port です。
  3. サーバーへの接続が完了したら実行する処理を、Startメソッドなどで次のように登録します。
           tcpCommunication.onConnect += ()=>{
               panel.SetActive(false);// サーバに接続したら背景のパネルを消去
               TcpAppServer.onGUI_flag = false;//onGUI用操作画面の表示を抑止(デバックの場合はtrueに設定)
               TcpAppServer.onGUI_flagClient = false;//onGUIクライアントの接続部表示のみ消す
               TcpAppServer.onGUI_flagServer = false;//onGUIサーバ起動部表示のみ消す
           };
  4. 上記が呼び出されたた後は、受信スレッドが動いています。

接続完了後の送受信

サーバー側、クライアント側共に、送信処理は同じで、接続相手を管理するTcpCommunicationオブジェクトのSendMessageメソッドで送信できます。
接続済みかどうかの判定は、cpCommunicationオブジェクトの.isRecRunning参照で可能です。
サーバー側とクライアント側の受信処理は、次のように大きく異なります。

サーバー側の受信処理

Startメソッドなどで、次のように用意すると良いでしょう。
        TcpAppServer.onReceive += (TcpCommunication com, BinPacket msg) =>
        {   
		// commのクライアントからのの受信処理(この例では、msgの文字列を受信)
        };

クライアント側の受信処理

FixedUpdate()などで、次のように考えれば良いでしょう。
        BinPacket binPacket = null;
        for (;;)
        { // 受信情報の取り出し
            BinPacket recPacket = QueueBuffer.GetBuff(tcpCommunication.queueBuffer);
            if (recPacket == null) break;
            // recPacketの受信データを使った何かしらの処理・・・
        }


TcpCommunication.csとTcpAppServer.csの主要なpublicメンバ説明

(詳細は、ソースコードを参照してください。)
TcpAppServer.csのTcpAppServerクラス(Gameobjectにアタッチして使うMonoBehaviour継承クラス)
変数 List<TcpCommunication> clientlist =
new List<TcpCommunication>()
接続を許可したクライアントのリスト。これで、このリスト要素(接続中の相手)を指定して、BinPacketを送信できます。
変数Dictionary<string, TcpCommunication> clientmap =
new Dictionary<string, TcpCommunication>();
接続を許可したクライアントを"IPアドレス:ポート番号"のキーで記憶するマップ
変数TcpListener tcpListener = null; サーバー用オブジェクト(接続待機)
変数bool onGUI_flag = true; このOnGUIの接続用GUIを利用しない場合にfalse
static
変数
string onGUI_message = ""; 接続時の進行を表示する文字列で、onGUI_flagをfalseにすると、表示されなくなる。 自前のプロフラムのデバックなどで、自由に設定使用できるが、進行の表示で勝手に上書されるため、 TcpAppServer.onGUI_message +="希望の文字列";のように連結処理で表示させるよい。
static
変数
bool onGUI_flagClient = true; クライアント接続用のボタンなどの表示制御
static
変数
bool onGUI_flagServer = true; サーバ接続用のボタンなどの表示制御
static
変数
float onGUI_startY = 20; OnGUI部品を並べ始める縦の基準座標
他に、onGUI_width = 400;OnGUI()で使う表示幅変更、onGUI_stepYのOnGUI部品を並べ始める縦の基準Y座標、 onGUI_height、onGUI_TextFieldFontSize、など多数あります。
メソッドStartServer() onGUIのサーバ起動用ボタン操作で呼び出されるので、それを利用する場合は呼び出す必要がない。 自前で呼び出すと、(ipAdrs, TcpCommunication.tcp_port)のバインドで、クライアント接続待ちスレッドが起動する。
static
メソッド
TcpAppServer.onTcpServerStart += (TcpAppServer server) =>{
}
サーバ起動イベントの処理:StartServer()のクライアント接続待ちスレッド内で、クライアントが接続して許可した時に実行させる記述を書く。 例えば、TcpAppServer.onGUI_flagServer = false;などonGUI用操作表示部の制御などに使える。
また、サーバークライアント兼用作品の場合は、自身のクライアントの接続処理(tcpCommunication.ConnectTo()など)を書く
static
メソッド
TcpAppServer.onAcceptTcp += (TcpCommunication com) =>{
}
クライアントが接続すると、自動的に接続を許可して、そのcomのクライアントの受信スレッドが始まるが、そに接続を許可で呼び出される。 (作品によって使わなくてもよい。接続直後にクライアントにデータを送るプロトコルにする場合などは、ここに、例えば次のように書く) com.SendMessage(msg); msgは文字列またはBinPacketのバイナリを指定できます。
static
メソッド
TcpAppServer.onReceive += (TcpCommunication com, BinPacket messaeg) =>{
}
サーバーの受信処理。クライアントの受信スレッドが自動的に動作して、この中でcomのクライアントの受信すると、このデリゲートが呼び出される。 利用者は、このmessaeg受信パケットに対応プログラムを、ここに書くことになる。例えば、comに送信する場合は、次のようになる。
com.SendMessage(msg); msgは文字列またはBinPacketのバイナリを指定できます。
メソッドResetTcpServer() サーバーを停止。これを実行して停止した後に再びサーバー起動する場合は、StartServer()を実行するとよい。


TcpCommunication.cs内のcpCommunicationクラス
(存在するだけでアタッチする必要がない。TcpAppServer.csをアタッチすると呼び出される)
(詳細は、ソースコードを参照してください。)
static
変数
string target_IP = "" 接続先IPアドレスで、OnGUIの接続用GUIを使うとオペレータの入力文字列が自動的にセットされる。 また何も指定しない場合、自身のローカルアドレスがTcpAppServerで自動的に設定される。
static
変数
int tcp_port = 3000; 接続先プログラムのポート番号で、一般的にStart()などで設定し直して使う。TcpAppServerのサーババインド用で利用される。 OnGUIの接続用GUIで、オペレータによる変更が可能ですが、その初期表示にも使われ、入力文字列が自動的にセットされる。
変数TcpClient tcpClient; 接続ソケット情報(これを利用して、下記のConnectTo()を実行します。)
メソッドasync Task ConnectTo() 呼び出すと上記(target_IP, tcp_port)の接続先サーバーにTCP接続要求を行うメソッド。
この実行後に、接続待機が非同期で行われる。一般に下記onGUIの接続用ボタン操作で呼び出す。
static
変数
TcpCommunication.tcpClientButton += () => {
};
ユーザーの接続ボタンの処理。TcpCommunicationオブジェクトのConnectTo();などの接続処理をここで行います。
static
変数
TcpCommunication.onConnect += () => {
};
サーバーの接続許可で実行するクライアント側のイベント処理。ここでアプリのメイン機能などをスタートするとよい。 この呼び出し直後に受信スレッドが起動して、受信したデータはBinPacketのパケットセットされて、QueueBufferのキューに蓄えられます。
受信処理プログラムは、このキューを監視して、適宜に取り出して処理しなければなりません。
static
変数
string clientId = "ShipA"; クライアント識別IDで、OnGUIの接続用GUIの初期表示にも使われ、入力文字列が自動的にセットされる。
利用者はStart()などで設定し直して使う。使い方は自由で、使わなくても良い。クライアントを名前で管理したい場合にあると、便利かも?ということで用意した。

変数
object messageObject=null 上記clientIdはstaticなので、クライアント側のアプリで利用で自由に使える変数となるが、これはサーバ側のclientlist要素で自由に設定することを期待した変数
static
メソッド
void OnGUI_Sub() この呼び出しを、MonoBehaviour継承クラスで行えば、OnGUIで、onGUI_messageに設定される状態通知(エラーなど)が、表示できます。 表示する位置やサイス情報は、onGUI_〜の変数である程度の表示位置やサイス指定が可能です。
例えば、QueueBufferで使うキューが一杯になった場合は「"QueueBuffer Full!"」が設定されて表示します。 利用者がデバックなどで、TcpCommunication.onGUI_message+=で表示文字列を設定しても構いません。
(TcpAppServer.onGUI_flagをfalseに設定することで、非表示にできまさす。)
変数bool isRecRunning = false; 受信スレッドが動いている間だけtrue
メソッドvoid SendMessage(string msg) 接続相手への文字列送信メソッド。msgは内部でUTF-8として処理される。
メソッドvoid SendMessage(byte [] msg) 接続相手へのバイナリ配列の送信メソッド
メソッドResetTcpClient() 一度、エラーなどで接続を切って、再びConnectTo()を行う場合に使うリセット


TcpCommunication.cs内のBinPacketクラス
(TcpCommunication通信で使うパケット)
(詳細は、ソースコードを参照してください。)
static
変数
int topSize = 4; このパケットの基本は、任意バイト長を管理するパケットで、先頭にバイト数を管理する領域があり、その領域サイスを指定する。 これが2であればUInt16(65535byteまで)、4であればUInt32 (2147483647byteまで)の送信サイスとする。
2byteに変更する場合で、設定してから使います。(この記憶は、ビックエンディアンで行われる)
 BinPacket(byte[] packet) 引数のバイト列を参照して記憶するコンストラクタ
static
メソッド
byte[] makePacketBinary(byte[] data) 送信用のバイト列取得メソッドで、dataの前にtopSizeバイトのサイズデータを埋め込んだbyte列を生成して返す。
static
メソッド
byte[] getData(byte[] packet) 上記のmakePacketBinaryで送られた、バイナリから先頭のサイズを除いてバイト列を取り出して返す。
(受信用メソッドです。BinPacketインスタンスのpacketを引数で指定し、内容のbyte列を取り出して返す。)
static
メソッド
byte[] makePacketBinary(String data) 送信用のバイト列取得メソッドで、dataの前にtopSizeバイトのサイズデータを埋め込んだbyte列を生成して返す。
static
メソッド
byte[] getString(byte[] packet) 上記のmakePacketBinaryで送ったstringを引数の受信バイナリからを取り出して返す。
(受信用メソッドです。BinPacketインスタンスのpacketを引数で指定し、内容のstringを取り出す。)
メソッドoverride string ToString() 受信用メソッドです。 BinPacketインスタンスのpacketを引数で指定し、内容のstringを取り出す。


TcpCommunication.cs内のQueueBufferクラス
(TcpCommunication通信で受信したパケットを記憶するキュー構造を管理するクラス)
(詳細は、ソースコードを参照してください。)
変数int number = 0; バッファに記憶される個数
 QueueBuffer(int size) コンストラクタ 引数の個数の要素を持つリング構造のキューバッファを生成します。
static
メソッド
bool Is_Full(QueueBuffer buff) バッファが一杯で、記憶できなくなったらtrueになります。
(TcpCommunicationの受信スレッドで使っており、エラーメッセージを出しています。)
static
メソッド
void SetBuff(QueueBuffer queueBuffer, BinPacket packet) キューにpacketを入れる。
packetをqueueBuffer追加する。(上記Is_Full()がfalseの場合だけ実行する使い方をしなければならない)
static
メソッド
BinPacket GetBuff(QueueBuffer queueBuffer) キューからの取り出して返す。キューが空の場合はnullを返す。 上記のSetBuffと排他制御をしており、それぞれを別スレッド使っても問題が起きないように工夫している。


TcpCommunication.csとTcpAppServer.csのソースコード

TcpCommunication.csのソース

using UnityEngine;
using System.Net.Sockets;
using System.Threading;
using System.Text;//Encoding
using System.IO;//StreamWriter,StreamReader
using System;
using System.Threading.Tasks;
using System.Buffers.Binary;

public class TcpCommunication 
{
    public delegate void TcpClientStartButtonDlegate();// TcpAppServerのOnGUIクライアント起動ボタンで実行するメソッド
    private static void dummyTcpClientStartButton() { Debug.Log($"dummy message Connect to {target_IP}:{tcp_port}"); }
    public static TcpClientStartButtonDlegate tcpClientButton = dummyTcpClientStartButton;

    public delegate void OnConnectDlegate();// サーバに接続できた時に実行するメソッド
    private static void dummyOnConnect() { Debug.Log($"Connected {target_IP}:{tcp_port}!"); }
    public OnConnectDlegate onConnect = dummyOnConnect;

    // 接続時の変数
    public static string target_IP = "";//接続先IP
    public static int tcp_port = 3000;    //接続先プログラムのポート番号
    public static string clientId = "ShipA";// クライアント識別ID

    public string netRemoteString = ""; // 送信先の先頭文字列(IPアドレス:ポート番号)
    public string netLocalString = ""; // 自身の先頭文字列(IPアドレス:ポート番号)

    public QueueBuffer queueBuffer = new QueueBuffer(1024);//メッセージ受信で使うキュー

    Thread mythread;//using System.Threading;が必要 (クライアントの受信スレッド)
    bool isConnectWaiting = false;// TCP接続の待機中だけTrue

    public static SynchronizationContext context; // 別スレッドからUnitのGUIやGameObjectを操作する時にこれを介す。
    Thread myRecthread;////受信処理スレッド
    public bool isRecRunning = false;// 受信スレッドが動いている間だけ true になる。

    public TcpClient tcpClient; //接続ソケット情報
    public Stream stream;// バイト列の送受信用

    //public static Encoding encoding = new UTF8Encoding(false);

    public object messageObject = null;//このクラスを利用するアプリケーション側で、自由に利用して構わない変数

    public static string onGUI_message = "";// 状態通知(エラーなど)
    public static float onGUI_startY = 400;// OnGUI部品を並べ始める縦の基準座標
    public static float onGUI_height = 300;// OnGUI部品高さのパラメタ
    public static float onGUI_startX = 10;// OnGUI部品を並べ始める横の基準座標
    public static float onGUI_width = 700;// OnGUI部品幅のパラメタ
    public static Color onGUI_color = Color.black;// OnGUI部品幅のパラメタ
    public static int onGUI_LabelFontSize = 26;// OnGUI部品幅のパラメタ

    public TcpCommunication()// 接続用と、サーバ側の両方で使う
    {
        if (TcpCommunication.context == null && SynchronizationContext.Current != null)
        {   // 未設定で、有効な状態の場合だけ設定
            TcpCommunication.context = SynchronizationContext.Current;
        }
        if (TcpCommunication.context == null) Debug.Log("TcpCommunication.contextが未設定");
    }

    // Tcpサーバ側で使うコンストラクタ
    public TcpCommunication(TcpClient tcpClient) : base()
    {
        this.tcpClient = tcpClient;
        this.netRemoteString = tcpClient.Client.RemoteEndPoint.ToString();// GetHeaderString(tcpClient);
        this.netLocalString = tcpClient.Client.LocalEndPoint.ToString();
        this.stream = tcpClient.GetStream();
        //Debug.Log("TcpCommunication サーバ用コンストラクタ実行");
        this.myRecthread = new Thread(new ThreadStart(MyRecThread));//受信処理スレッド
        this.myRecthread.Start();
    }

    public async Task ConnectTo()// staticの(string target_IP, int tcp_port)で接続するクライアント側用
    {
        if (isRecRunning)// 受信スレット動作中
        {
            return;
        }
        if (isConnectWaiting)// 接続を待つ
        {
            return;// 接続待機中であれば、何もしない。
        }

        ResetTcpClient();
        isConnectWaiting = true;// 接続中
        try
        {
            if (tcp_port > 65535) throw new Exception("ポート番号が大きい過ぎでエラー");

            // 接続処理
            await Task.Run(() =>
            {
                this.tcpClient = new TcpClient(target_IP, tcp_port); //サーバに接続する。
            });
            this.netRemoteString = tcpClient.Client.RemoteEndPoint.ToString();//GetHeaderString(this.tcpClient);
            this.netLocalString = tcpClient.Client.LocalEndPoint.ToString();
            context.Post((state) =>// GUI操作
            {
                onConnect();// 接続した時のdelegateメソッド
            }, null);
            mythread = new Thread(new ThreadStart(MyRecThread));//受信スレッドの生成
            mythread.Start();//受信スレッドのスタート
        }
        catch (Exception err)
        {
            onGUI_message += err.Message + "\r\n";

        }
        isConnectWaiting = false;// 接続終了
    }

    public void ResetTcpClient()
    {
        if (stream != null)
        {
            stream.Dispose();
            stream = null;
        }
        isRecRunning = false;
        if (this.tcpClient != null)
        {
            this.tcpClient.Dispose();
            this.tcpClient = null;
        }
    }

    // サーバー、クライン等検討の受信スレッド(受信したBinPacketをQueueBufferに記憶)
    private void MyRecThread()
    {
        isRecRunning = true;
        this.stream = tcpClient.GetStream();
        string partner = $"{tcpClient.Client.RemoteEndPoint}";
        // Debug.Log("MyRecThread.実行開始");

        while (isRecRunning)
        {
            try
            {
                if (QueueBuffer.Is_Full(this.queueBuffer))
                {
                    System.Threading.Thread.Sleep(1);//1m秒待ち
                    onGUI_message = $"QueueBuffer Full!";
                    continue;
                }

                BinPacket packet = BinPacket.getBinPacket(this.stream);

                QueueBuffer.SetBuff(queueBuffer, packet);
                TcpCommunication.onGUI_message = $"MyRecThread {packet.packet.Length}byte 受信";
                // Debug.Log($"MyRecThread {packet.packet.Length}byte(先頭{BinPacket.topSize}byteはサイズ) 受信");
            }
            catch (Exception err)
            {
                isRecRunning = false;
                onGUI_message = $"受信スレッド MyRecThreadを、不具合により終了!{err}";
                break;
            }
        }
        isRecRunning = false;
    }

    public void SendMessage(string msg)// 接続相手への送信メソッド
    {
        if (this.stream == null) return;
        byte[] packet = BinPacket.makePacketBinary(msg);// 送信データ取得
        this.stream.Write(packet, 0, packet.Length);//バイナリ送信
        TcpCommunication.onGUI_message = $"SendMessage: {packet.Length}byteを \"{msg}\"の文字列を送信";
        // Debug.Log($"SendMessage: {packet.Length}byte(先頭{BinPacket.topSize}byteはサイズ)で \"{msg}\"を文字列を送信");
    }
    public void SendMessage(byte [] msg)// 接続相手への送信メソッド
    {
        if (this.stream == null) return;
        byte[] packet = BinPacket.makePacketBinary(msg);// 送信データ取得
        this.stream.Write(packet, 0, packet.Length);//バイナリ送信
        TcpCommunication.onGUI_message = $"SendMessage: {packet.Length}byteをbyte配列を送信";
        // Debug.Log($"SendMessage: {packet.Length}byte(先頭{BinPacket.topSize}byteはサイズ)をbyte配列を送信");
    }
    public static void OnGUI_Sub()// TcpCommunication.onGUI_messageの文字列を表示する。(OnGUIで呼び出して利用する)
    {
        if (TcpAppServer.onGUI_flag == false) return;
        GUIStyle lableStyle = GUI.skin.GetStyle("label");   // ---Labelスタイル設定
        lableStyle.fontSize = onGUI_LabelFontSize;
        GUI.color = onGUI_color;//文字色設定
        GUI.Label(new Rect(onGUI_startX, onGUI_startY, onGUI_width, onGUI_height), $"{ TcpCommunication.onGUI_message }");
    }
}// TcpCommunication

public class BinPacket // TcpCommunicationの送受信で使うパケットの出し入れ用staticメソッド群などを定義
{
    public static int topSize = 4;// 先頭に、2であればUInt16、4であればUInt32 の送信サイスとする。
    internal byte[] packet;//QueueBufferに管理させる(先頭にサイズを埋め込んだuInt16の2byteあり)

    private BinPacket() { }
    public BinPacket(byte[] packet) { this.packet = packet; }

    // 送信用のバイト列取得用
    internal static byte[] makePacketBinary(byte[] data)// dataの前にサイズデータを埋め込んだbyte列を生成して返す。
    {
        // 先頭2または4byteにビックエンディアンビットサイズ(最大byte:65535または2147483647)があるバイト列
        byte[] packet = new byte[data.Length + topSize];
        if (topSize == 2 && packet.Length < 65535) BinaryPrimitives.WriteUInt16BigEndian(packet, (ushort)data.Length);
        else if (topSize == 4 && packet.Length < 2147483647) BinaryPrimitives.WriteUInt32BigEndian(packet, (uint)data.Length);
        else throw new Exception("makePacketBinary: bytes size exceeded.");
        Buffer.BlockCopy(data, 0, packet, topSize, data.Length);
        return packet;
    }
    // 送信時のバイト列取得用
    public static byte[] makePacketBinary(String data)// buffer先頭にサイズデータを埋め込んだbyte列を取得
    {
        byte[] sbuff = System.Text.Encoding.UTF8.GetBytes(data);
        // 先頭2または4byteにビックエンディアンビットサイズ(最大byte:65535または2147483648)があるバイト列
        byte[] packet = new byte[sbuff.Length + topSize];
        if (topSize == 2 && packet.Length < 65535) BinaryPrimitives.WriteUInt16BigEndian(packet, (ushort)sbuff.Length);
        else if (topSize == 4 && packet.Length < 2147483647) BinaryPrimitives.WriteUInt32BigEndian(packet, (uint)sbuff.Length);
        else throw new Exception("makePacketBinary: string size exceeded.");
        Buffer.BlockCopy(sbuff, 0, packet, topSize, sbuff.Length);
        return packet;
    }

    // 受信用 BinPacketインスタンスのpacketを引数で指定し、内容のbyte列を取り出す。
    public static byte[] getData(byte[] packet)
    {   // TcpCommunicationオブジェクトのSendMessage(byte [] msgg)で送ったパケットからの取り出し
        UInt32 count = 0;
        if (topSize == 2) count = BinaryPrimitives.ReadUInt16BigEndian(packet);//後ろに並ぶbyte数を取得
        else if (topSize == 4) count = BinaryPrimitives.ReadUInt32BigEndian(packet);//後ろに並ぶbyte数を取得
        byte[] data = new byte[count];
        Buffer.BlockCopy(packet, topSize, data, 0, (int)count);// 2147483648以下
        return data;
    }


    // 受信用 BinPacketインスタンスのpacketを引数で指定し、内容のstringを取り出す。
    internal static string getString(byte[] packet)
    {   // TcpCommunicationオブジェクトのSendMessage(string msg)で送ったパケットからの取り出し
        UInt32 count = 0;
        if (topSize == 2) count = BinaryPrimitives.ReadUInt16BigEndian(packet);//後ろに並ぶbyte数を取得
        else if (topSize == 4) count = BinaryPrimitives.ReadUInt32BigEndian(packet);//後ろに並ぶbyte数を取得
        byte[] sbuff = new byte[count];
        Buffer.BlockCopy(packet, topSize, sbuff, 0, (int)count);// 2147483648以下
        try
        {
            string s = System.Text.Encoding.UTF8.GetString(sbuff, 0, (int)count);// stringのobjectを復元
            return s;
        }
        catch (Exception e) {
            return $"{count}byteのBinPacket {e.ToString()}";
        }
    }

    public override string ToString()
    {
        return getString( this.packet );
    }

    // 受信streamからBinPacketを生成して返す
    internal static BinPacket getBinPacket(Stream stream)
    {
        // 受信バイトを取得
        byte[] aCount = new byte[topSize];
        int n = aCount.Length;
        int idx = 0;
        while(idx < n)
        {
            idx += stream.Read(aCount, idx, n - idx);
        }
        UInt32 count = 0;
        if (topSize == 2) count = BinaryPrimitives.ReadUInt16BigEndian(aCount);//後ろに並ぶbyte数を取得
        else if (topSize == 4) count = BinaryPrimitives.ReadUInt32BigEndian(aCount);//後ろに並ぶbyte数を取得
        byte[] packet = new byte[topSize + count];
        Buffer.BlockCopy(aCount, 0, packet, 0, topSize);
        n = packet.Length;
        while (idx < n)
        {
            idx += stream.Read(packet, idx, n - idx);
        }
        BinPacket binPacket = new BinPacket();
        binPacket.packet = packet;
        return binPacket;
    }

    public static void Test()// テストコード
    {
        BinPacket binPacket = new BinPacket();
        binPacket.packet = BinPacket.makePacketBinary("abcあいう");// パケットに入れる。
        Debug.Log(BinPacket.getString(binPacket.packet));// 上記が表示されればOK

        byte[] data = { 0, 1, 2, 3 };
        binPacket.packet = BinPacket.makePacketBinary(data);// パケットに入れる。
        byte [] data2 = BinPacket.getData(binPacket.packet);
        for(int i=0; i < data2.Length; i++)
        {
            Debug.Log(data2[i] == data[i]);// すべてtrueならOK
        }
    }
}// end BinPacket class


// QueueBufferを生成した後、これを引数するstaticメソッド群で操作するためのクラス
public class QueueBuffer
{
    private object lockObj = new object();
    bool mutex = false; // trueにした関数だけが、ringBufferを出し入れできる。
    BinPacket [] ringBuffer = null;
    int in_idx = -1;
    int outIdx = -1;
    public int number = 0;//上記ringBufferバッファに記憶される個数


    public QueueBuffer(int size)
    {
        ringBuffer = new BinPacket[size];
    }

    public static bool Is_Full(QueueBuffer buff)
    {
        return buff.number == buff.ringBuffer.Length;
    }

    // packetをqueueBuffer追加する。(上記Is_Full()がfalseの場合だけ実行する使い方をしなければならない)
    public static void SetBuff(QueueBuffer queueBuffer, BinPacket packet)// キューにpacketを入れる
    {
        while (true)
        {
            lock (queueBuffer.lockObj)// ロック
            {
                if (queueBuffer.mutex == false)
                {
                    queueBuffer.mutex = true;
                    break;
                }
            }
            System.Threading.Thread.Sleep(1);//1m秒待ち
        }
        queueBuffer.number++;
        if (queueBuffer.in_idx + 1 >= queueBuffer.ringBuffer.Length)
        {
            queueBuffer.in_idx = -1;
        }
        queueBuffer.ringBuffer[++queueBuffer.in_idx] = packet;
        queueBuffer.mutex = false;
    }

    // queueBufferが空(buff.numberが0)であれば、nullを返す。空でなければバッファから抜き出して返す。
    public static BinPacket GetBuff(QueueBuffer queueBuffer)// キューからの取り出し
    {
        if (queueBuffer.number == 0) return null;
        lock (queueBuffer.lockObj)// ロック
        {
            if (queueBuffer.mutex) return null;
            queueBuffer.mutex = true;
        }
        queueBuffer.number--;
        if (queueBuffer.outIdx + 1 >= queueBuffer.ringBuffer.Length)
        {
            queueBuffer.outIdx = -1;
        }
        BinPacket binPacket = queueBuffer.ringBuffer[++queueBuffer.outIdx];
        queueBuffer.mutex = false;
        return binPacket;
    }
}


TcpAppServer.csのソース

using UnityEngine;
using System.Collections.Generic;
using System.Net;
using System.Threading;
using System.Net.Sockets;
using System;

// Canvas か、専用に作ったGameObjectにアタッチして使うのがよいでしょう。
public class TcpAppServer : MonoBehaviour
{
    public static TcpAppServer instance; // 自身のインスタンスを記憶

    public delegate void OnTcpServerStartDlegate(TcpAppServer server);// サーバ起動で実行するメソッド
    private static void dummyTcpServerStart(TcpAppServer server) { Debug.Log($"onTcpServerStart"); }
    public static OnTcpServerStartDlegate onTcpServerStart = dummyTcpServerStart;

    public delegate void OnAcceptTcpDelegate(TcpCommunication com);// 接続許可で実行するメソッド
    private static void dummyAcceptTcp(TcpCommunication com) { Debug.Log($"onAcceptTcp:[{com.netRemoteString}]"); }
    public static OnAcceptTcpDelegate onAcceptTcp = dummyAcceptTcp;

    public delegate void OnReceiveDelegate(TcpCommunication com, BinPacket messaeg);// 受信で実行するメソッド
    private static void dummyOnReceive(TcpCommunication com, BinPacket msg) { Debug.Log($"[{com.netRemoteString}]:{msg}"); }
    public static OnReceiveDelegate onReceive = dummyOnReceive;

    static string host = System.Net.Dns.GetHostName();  //使用しているマシンのhost名を取得
    public static IPAddress ipAdrs = null;       // サーバIPアドレス

    public TcpListener tcpListener = null; //サーバー用オブジェクト(接続待機)

    // 接続相手のTcpCommunicationの集合を管理
    public List<TcpCommunication> clientlist = new List<TcpCommunication>();
    public Dictionary<string, TcpCommunication> clientmap = new Dictionary<string, TcpCommunication>();

    Thread startServerThread;//using System.Threading;が必要 (クライアントの接続待ち)
    bool isStartServer = false;

    SynchronizationContext context; // 別スレッドからUnitのGUIやGameObjectを操作する時にこれを介す。

    IPHostEntry ipHostEntry = null; //このホストの情報取得
    IPAddress[] adrList = null; //IPアドレスのリストを取得用

    public static bool onGUI_flag = true;// このOnGUIの接続用GUIを利用しない場合にfalse
    public static bool onGUI_flagClient = true;// クライアント接続用のボタン表示
    public static bool onGUI_flagServer = true;// サーバー起動用のボタン表示
    public static float onGUI_startY = 20;// OnGUI部品を並べ始める縦の基準座標
    public static float onGUI_stepY = 40;// OnGUI部品を並べる縦の間隔のパラメタ
    public static float onGUI_height = 60;// OnGUI部品高さのパラメタ
    public static float onGUI_startX = 10;// OnGUI部品を並べ始める横の基準座標
    public static float onGUI_width = 700;// OnGUI部品幅のパラメタ
    public static int onGUI_LabelFontSize = 26;// OnGUI部品文字サイスのパラメタ
    public static int onGUI_ButtonFontSize = 36;// OnGUI部品文字サイスのパラメタ
    public static int onGUI_TextFieldFontSize = 36;// OnGUI部品文字サイスのパラメタ   
    public static string onGUI_message = "";// onGUI 用のメッセージ表示

    void Start()
    {
        instance = this;//自身のインスタンス
        this.context = SynchronizationContext.Current;
        TcpCommunication.context = SynchronizationContext.Current;

        // このホストがIPアドレス情報
        this.ipHostEntry = Dns.GetHostEntry(host); //このホストの情報取得
        this.adrList = ipHostEntry.AddressList; //IPアドレスのリストを取得

        if (this.adrList == null || adrList.Length == 0)
        {
            onGUI_message = "ネットワーク環境が使えません。";
            return;
        }

        int defalt_net_idx = 0;
        for (int idx = 0; idx < adrList.Length; idx++)// サーバで使えるIPアドレスボタン列挙
        {
            string ip = adrList[idx].ToString();
            if (ip.IndexOf('.') != -1) defalt_net_idx = idx; // 列挙最後IPv4
            // Debug.Log($"{ip}");//IPアドレスのボタン
        }
        if (TcpCommunication.target_IP == "")// 未設定であれば列挙最後IPv4をデフォルトにする。
        {
            TcpCommunication.target_IP = this.adrList[defalt_net_idx].ToString(); ;
        }
    }

    // 起動時の操作やデバック用のユーザインターフェイス(onGUI_flag、onGUI_flagClient、onGUI_flagServerで利用範囲指定)
    private void OnGUI()
    {
        if (onGUI_flag == false) return;
        float yPos = onGUI_startY;
        GUI.backgroundColor = Color.cyan;// new Color(0.9f,0.9f,0.9f);
        GUIStyle lableStyle = GUI.skin.GetStyle("label");   // ---Labelスタイル設定
        lableStyle.fontSize = onGUI_LabelFontSize;
        GUIStyle buttonStyle = GUI.skin.GetStyle("button"); // ---Buttonスタイル設定
        buttonStyle.fontSize = onGUI_ButtonFontSize;
        GUI.skin.textField.fontSize = onGUI_TextFieldFontSize; // ---TextFieldスタイル設定
        if (onGUI_flagClient)
        {
            GUI.color = Color.black;//文字色設定
            GUI.Label(new Rect(onGUI_startX, yPos, onGUI_width / 2, onGUI_height), "クライアント名");
            GUI.color = Color.yellow;//文字色設定
            TcpCommunication.clientId = GUI.TextField(
                new Rect(onGUI_width / 2, yPos, onGUI_width / 2, onGUI_height), TcpCommunication.clientId, 20, GUI.skin.textField);
            yPos += onGUI_stepY * 2;

            GUI.color = Color.black;//文字色設定
            GUI.Label(new Rect(onGUI_startX, yPos, onGUI_width, onGUI_height), "以下が接続先サーバのIPアドレス(編集可能)");
            yPos += onGUI_stepY;
            GUI.color = Color.yellow;//文字色設定
            TcpCommunication.target_IP = GUI.TextField(
                new Rect(onGUI_startX, yPos, onGUI_width, onGUI_height), TcpCommunication.target_IP, 50, GUI.skin.textField);
            yPos += onGUI_stepY * 2;
        }
        if (onGUI_flagClient || onGUI_flagServer)
        {
            GUI.color = Color.black;//文字色設定
            GUI.Label(new Rect(onGUI_startX, yPos, onGUI_width / 2, onGUI_height), "TCP接続のポート番号");
            GUI.color = Color.yellow;//文字色設定
            string str_tcp_port = TcpCommunication.tcp_port + "";
            str_tcp_port = GUI.TextField(
                new Rect(onGUI_width / 2, yPos, onGUI_width / 2, onGUI_height), str_tcp_port, 6, GUI.skin.textField);
            try
            {
                TcpCommunication.tcp_port = int.Parse(str_tcp_port);
            }
            catch (Exception) { }
            yPos += onGUI_stepY * 1.5f;
        }
        if (onGUI_flagClient)
        {
            GUI.color = Color.black;//文字色設定
            GUI.Label(new Rect(onGUI_startX, yPos, onGUI_width, onGUI_height), "上記サーバのクライアントとして始める場合は、");
            yPos += onGUI_stepY;
            GUI.Label(new Rect(onGUI_startX, yPos, onGUI_width, onGUI_height), "次の接続ボタンをクリックしてください。");
            yPos += onGUI_stepY;
            GUI.color = Color.yellow;//文字色設定
            if (GUI.Button(new Rect(onGUI_startX, yPos, onGUI_width, onGUI_height), "サーバに接続する"))//IPアドレスのボタン
            {
                TcpCommunication.tcpClientButton();
            }
            yPos += onGUI_stepY * 3;
        }
        if (onGUI_flagServer)
        {
            GUI.color = Color.black;//文字色設定
            GUI.Label(new Rect(onGUI_startX, yPos, onGUI_width, onGUI_height), "サーバーとして始める場合は、以下のIPアドレスボタンから");
            yPos += onGUI_stepY;
            GUI.Label(new Rect(onGUI_startX, yPos, onGUI_width, onGUI_height), "使用するアドレスを選んで、クリックしてください。");
            yPos += onGUI_stepY;

            GUI.color = Color.yellow;//文字色設定
            for (int idx = 0; idx < adrList.Length; idx++, yPos += onGUI_stepY * 1.8f)// サーバで使えるIPアドレスボタン列挙
            {
                if (GUI.Button(new Rect(onGUI_startX, yPos, onGUI_width, onGUI_height), adrList[idx].ToString()))//IPアドレスのボタン
                {
                    ipAdrs = adrList[idx];// サーバに使うIPアドレスを記憶
                    TcpCommunication.target_IP = adrList[idx].ToString();
                    StartServer();//サーバーの起動
                }
            }
        }

        GUI.color = Color.black;//文字色設定
        //this.msgError = "起動しました。";
        if (onGUI_message!="") GUI.Label(new Rect(onGUI_startX, yPos, onGUI_width, onGUI_height), $"Message:{ onGUI_message }");
        yPos += onGUI_stepY*2;

        if (this.clientlist.Count > 0)
        {
            GUI.Label(new Rect(onGUI_startX, yPos, onGUI_width, onGUI_height), $"接続数:{ this.clientlist.Count }");
            yPos += onGUI_stepY;
            TcpCommunication com = this.clientlist[this.clientlist.Count - 1];
            GUI.Label(new Rect(onGUI_startX, yPos, onGUI_width, onGUI_height), $"最後に接続した相手{com.netRemoteString} ");
        }
    }

    // サーバ再起動の準備
    public void ResetTcpServer()
    {
        for( int n=0; n < this.clientlist.Count; n++)
        {
            this.clientlist[n].ResetTcpClient();// 各クライアントオブジェクトをリセット
        }
        if (this.isStartServer)
        {
            tcpListener.Stop();
        }
        this.isStartServer = false;
        tcpListener = null;
    }

    // TCPサーバ起動関連処理(スレッド起動)
    public void StartServer()
    {
        try
        {
            tcpListener = new TcpListener(ipAdrs, TcpCommunication.tcp_port); //サーバー用オブジェクト生成
            tcpListener.Start();
            this.isStartServer = true; //LISTENING 状態
        }
        catch (Exception e)
        {
            onGUI_message = $"サーバ起動失敗:{ipAdrs}:{TcpCommunication.tcp_port}, {e.Message}";
            return;
        }
        this.startServerThread = new Thread(new ThreadStart(StartServerThread));//クライアント待機スレッド
        this.startServerThread.Start();
        onTcpServerStart(this); // サーバー起動時のdelgateメソッド
    }

    // サーバ用待ち受けスレッド
    private void StartServerThread()
    {
        isStartServer = true;
        TcpClient tcpClient = null;
        onGUI_message = $"サーバ{ipAdrs}:{TcpCommunication.tcp_port}待ち受けスレッド起動!";
        try
        {
            while (isStartServer)
            {
                tcpClient = tcpListener.AcceptTcpClient(); //ライアントからの接続要求を受け入れる(接続を待っている)
                onGUI_message = "ライアントからの接続要求を受け入れ!";
                // サーバ側の受信スレッドも起動
                TcpCommunication tcpCommunication = new TcpCommunication(tcpClient);

                this.clientlist.Add(tcpCommunication);
                clientmap.Add(tcpCommunication.netRemoteString, tcpCommunication);
                context.Post((state) =>//  ここで、GUI用の処理の追加コードを可能にする
                {
                    TcpAppServer.onAcceptTcp(tcpCommunication);// delegateメソッド起動
                }, null);
            }
        }
        catch (Exception e)
        {
            onGUI_message = $"Error: StartServerThread: { e.Message }";
            isStartServer = false;
            return;
        }
        // Debug.Log("StartServerThread End");  
    }

    void FixedUpdate()
    {   // 全てのクライアントからの受信処理を実行
        for(int i=0; i < this.clientlist.Count; i++)
        {
            TcpCommunication com = this.clientlist[i];
            BinPacket packet = QueueBuffer.GetBuff(com.queueBuffer);//キューから取り出す
            if (packet != null)
            {
                onReceive(com, packet);// デリゲート受信処理
            }
        }
    }

    /// オブジェクトが破棄時に実行
    private void OnDestroy()
    {
        // Debug.Log("OnDestroy");
        ResetTcpServer();
    }
}


TcpCommunication.csとTcpAppServer.csを使った、ネットワーク・ホワイトボードの作成例

この作品を次のイメージのようにパッケージにしたファイル(NetWhiteBoard.unitypackage)は、
  →このリンクからダウンロードできます。
(Unity 2021.2以降でないと使えませんのご注意ください)
次の3つの作品が各フォルダに入っています。それぞれのフォルダ内のSceneを開いて実行できます。
ネットワークの作品は、Scriptフォルダ内のTcpCommunication.csとTcpAppServer.csを利用して動作します。
  1. EX_WhiteBoradフォルダがネットワーク無しの作品
     (シーンを単体で実行し、ドラックで描く作品)
  2. EX_WhiteBoradServerフォルダがネットワークサーバ側の作品
     (これを実行して、サーバを起動させます。次に下記クライアントを接続した後にドラックで描きます)
  3. EX_WhiteBoradClientフォルダがネットワーククライアント側の作品
     (サーバで実行させたIPアドレスを入力して、接続後、ドラックで描きます。)


ネットワーク化を考慮したネットワーク無しの作品作成

ネットワーク無しの作品を作って、次にそれをネットワークで動くように改良します。
目標はホワイトボードで、RawImage上でドラックすると描けるお絵かきできる作品です。
最終的にネットワーク化を考慮すると、画面共有のため、画面で使うTexture2DのColor32のpixels群を、ファイルに保存して、表示できる作品としました。
(ネットワークの送受信対象は、ファイル読み書きでデバックすると作りやすいからです。
ファイルストリームをネットストリームに変更したものがネットワークの通信プログラムとなります。)
この目標のホワイトボードは、次のようにドラックで赤の矩形を描く作品です。

(この動作ムービーを紹介したXへのリンク)

作品の構成は、上記画面でも分かるように、Canvas内がいっぱいとなるように配置したRawImageがあるだけの作品です。
このRawImageに360×360のサイズのTexture2Dを createTexture(int width, int height, Color32 color)メソッドで生成して設定するStartメソッドから始まっています。

そして、FixedUpdate()内のマウスボタンを押している時のイベントで、マウス位置のTextureのピクセル情報を赤に変更する処理を行っています。
この変更処理は、別途に作ったvoid draw(Color32 color, int x, int y)メソッドで行っています。
(他にマウス右ボタン押し込みで、画面を白に初期化しています。)

なお、画像をバイナリ化するbyte[] getBytesByPixels(Color32[] pixels)を定義して、 これを終了時に使ってバイナリファイルを作っています。
また、バイナリから画像を設定するvoid BytesToColor32(byte[] bytes, Color32[] pixels)を定義して、Startでファイルから読み取ったバイナリ情報で、rawImage.textureの画像を復元しています。
以上のプログラムを下記のWhiteBorad.csに作り、これをCanvasにアタッチするだけで、実現している作品です。
using System;
using System.IO;
using Unity.Collections;
using UnityEngine;
using UnityEngine.UI;

public class WhiteBorad : MonoBehaviour
{
    const int width = 360;// RawImageに設定するTexture2Dのサイズ
    const int height = 360;
    const string filePath = "white_board.bin";// textureの画像を保存するファイル名
    public static int brushSize = 3;// 上記色のこのサイズのい正方形をブラシとして使う
    RawImage rawImage;// Canvasいっぱいに配置したRawImageで、ここが描画対象

    void Start()
    {
        Time.fixedDeltaTime = 0.02f;//またはApplication.targetFrameRate = 50;

        GameObject rawImageObj = GameObject.Find("RawImage");
        this.rawImage = rawImageObj.GetComponent<RawImage>();
        Color32 color = new Color32(255, 255, 255, 255);
        this.rawImage.texture = createTexture(width, height, color);// color色のTexture2Dを生成;

        // filePathのファイルが存在すれば、そのファイル内容で初期表示
        FileInfo fileInfo = new FileInfo(filePath);
        if (true && fileInfo.Exists)
        {
            byte[] buff = new byte[fileInfo.Length];
            using (FileStream stream = File.Open(filePath, FileMode.Open))
            {
                int idx = 0;
                while (idx < buff.Length)
                {
                    idx += stream.Read(buff, idx, buff.Length - idx);// ファイルから読み込む
                }
            }
            Texture2D texture = (Texture2D)this.rawImage.texture;
            Color32[] pixels = texture.GetPixels32();
            BytesToColor32(buff, pixels);// ファイルから取得したバイト列でテクスチャピクセル設定
            texture.SetPixels32(pixels);
            texture.Apply();
        }
    }

    void FixedUpdate()
    {
        if (Input.GetMouseButton(1))// 右ボタン操作で消去
        {
            this.rawImage.texture = createTexture(width, height, new Color32(255, 255, 255, 255) );
        }
        if (Input.GetMouseButton(0))// 左ボタン操作
        {
            Vector3 pos = Input.mousePosition;
            Vector3 viewPos = Camera.main.ScreenToViewportPoint(pos);
            int x = (int)(viewPos.x * width);//マウス位置からピクセル位置を取得
            int y = (int)(viewPos.y * height);
            Debug.Log($"viewPos={viewPos}x ={x:000}y={y:000}");
            Color32 color = new Color32(255, 0, 0, 255);
            draw(color, x, y);// brushSizeで描画処理// 描画 赤
        }
    }

    public void OnApplicationQuit()// プログラム終了時に、rawImage.textureの画像をファイル化
    {
        Texture2D texture = (Texture2D)this.rawImage.texture;
        byte[] buff = getBytesByPixels(texture.GetPixels32());// textureの画像をバイナリ化
        using (FileStream stream = File.Open(filePath, FileMode.Create))
        {
            stream.Write(buff, 0, buff.Length);// ファイルに書き込み
        }
    }

    // this.rawImage.textureの画像のピクセル群で、(x,y)位置を左下としたbrushSizeの正方形をcolor色に設定
    private void draw(Color32 color, int x, int y)
    {
        int idx = y * height + x;// クリック位置からTexture2D内のピクセルを指定色に変更する。
        Texture2D texture = (Texture2D)this.rawImage.texture;
        Color32[] pixels = texture.GetPixels32();
        try
        {
            for (int y2 = 0; y2 < brushSize; y2++)
            {
                for (int x2 = 0; x2 < brushSize; x2++)
                {
                    pixels[idx + x2 + y2 * width] = color;
                }
            }
        }
        catch (Exception _) { }
        texture.SetPixels32(pixels);
        texture.Apply();
    }

    // color色で、width × heightのサイズのTexture2Dを生成する。
    public static Texture2D createTexture(int width, int height, Color32 color)
    {
        Texture2D texture = new Texture2D(width, height, TextureFormat.RGBA32, false);
        NativeArray<Color32> pixels = texture.GetRawTextureData<Color32>();
        Debug.Log($"{pixels.Length}  ( {texture.height}, {texture.width})");
        for (int i = 0; i < pixels.Length; i++)
        {
            pixels[i] = color;
        }
        texture.Apply();
        return texture;
    }

    // byte配列で、Texture2Dから取得したpixels配列を設定する(バイナリから画像を設定)
    public static void BytesToColor32(byte[] bytes, Color32[] pixels)
    {
        bool chk = bytes.Length == pixels.Length * 4;
        if (chk == false) throw new Exception("BytesToColor32: {bytes.Length]byteをpixelsに設定出来ません。");
        for (int idx_bytes = 0, idx = 0; idx < pixels.Length; idx++)
        {
            byte r = bytes[idx_bytes++];
            byte g = bytes[idx_bytes++];
            byte b = bytes[idx_bytes++];
            byte a = bytes[idx_bytes++];
            pixels[idx] = new Color32(r, g, b, a);
        }
    }

    // Texture2Dから取得したpixels配列からbyte配列からを生成して返す。(画像のバイナリ化)
    public static byte[] getBytesByPixels(Color32[] pixels)
    {
        byte[] bytes = new byte[pixels.Length * 4];
        for (int i = 0, idx_bytes = 0; i < pixels.Length; i++)
        {
            bytes[idx_bytes++] = pixels[i].r;
            bytes[idx_bytes++] = pixels[i].g;
            bytes[idx_bytes++] = pixels[i].b;
            bytes[idx_bytes++] = pixels[i].a;
        }
        return bytes;
    }
}

ネットワークを介した作品作成

起動時にサーバーかクライアントのどちらで使うかを決めてスタートするアプリケーションのスタイルと、 サーバー専用の実行ファイルと、クライアント専用の実行ファイルをそれぞれ用意するスタイルの作品が考えられます。
ここで紹介する作品は、後者のサーバーと、クライアントを、それぞれ用意するスタイルです
サーバーと、クライアントの起動時の画面例を示します。
サーバー起動画面クライアント起動画面

これら起動画面は、TcpAppServer.csをアタッチして、チョットコードを書けば、簡単に出すことができます。
そして、サーバー側で、利用するIPアドレスのボタンで自動的にクライアントからの接続を待機する状態になります。
以下は、操作による場面の移り変わり例です。
サーバーで
IPアドレスボタンを押した後の画面
クライアントで
接続ボタンを操作した直後のサーバ側画面
クライアントで
接続ボタンを押して、通信が始まった時の画面

上記表示の文字列はデバック用です。サーバと接続するまでの情報表示は、TcpAppServer .onGUI_messageの内容です。
接続後の通信に関する情報表示は、TcpCommunication .onGUI_messageの内容です。
これらのデバック的な情報表示の変数は設定は、「public static」なので、自身のプログラムから自由に設定可能です。
また、これらの表示は、TcpAppServer .onGUI_flagなどfalse設定で表示を抑止できます。

接続後の作品の仕様

TCPサーバー側実行可能ファイルは、クライアントから送られる"RED,100,20"のような文字列を受信して、 それを、自身のRawImageに描画する。
そのRawImageのイメージをFixedUpdate()で、接続しているすべてのクライアントに配信する。
また、マウス右ボタンで画面消去が出来る。
終了時に使って画像をバイナリファイルで保存し、起動時にそれを復元して表示する。

TCPクライアント側実行可能ファイルは、サーバーに接続すると、 FixedUpdate()でサーバからの受信チェックを行って、バイナリ受信があれば、それで自身のRawImageに描画する。
また、クライアントから送られる"RED,100,20"のような文字列を受信して、それを、自身のRawImageに描画する。
またFixedUpdate()では、マウス左ボタンを押したタイミングで、クライアント名に指定したを色と座標を文字列でサーバに送信する。

以下が動作イメージです。「Build And Run」のメニュー操作でクライアントを2つ起動し、 Unityエディタではサーバとして動かし、左クライアントで接続後、「へのへの」を描いた情報を サーバと共有した後、右のクライアントを接続しようとしている動作例です。

(この動作ムービーを紹介したXへのリンク)
このムービは、パッケージをインポートした後、EX_WhiteBoradClientSceneを開いて、「Build And Run」を2回実行し、 その後にシーンをEX_WhiteBoradSeverSceneで開き直してサーバをスタートさせています。


サーバ側のソース(WhiteBoradServer.cs:Canvasにアタッチ)と概要説明

前述の「ネットワーク化を考慮したネットワーク無しの作品」のソースを変更・追加しています。
(追加箇所が   の部分です。)
クライアントからの受信文字列(例:"Red,100,50")から色とドラック座標を得て、それでRawImage のテクスチャを変更して、 その画像のバイナリ情報を、接続している全てのクライアントに送信する処理です。
サーバの受信処理は、 TcpAppServer.onReceiveのデリゲート変数への設定メソッドで行われます。
using System;
using System.Collections.Generic;
using System.IO;
using Unity.Collections;
using UnityEngine;
using UnityEngine.UI;

public class WhiteBoradServer : MonoBehaviour
{
    float serverSendNextTiming; //サーバー側で、Time.timeが、この時間以上になったら情報を発信する。 
    const int width = 360;// RawImageに設定するTexture2Dのサイズ
    const int height = 360;
    const string filePath = "white_board.bin";
    Dictionary<string, Color32> colorMap = new Dictionary<string, Color32>(){
        {"R", new Color32(255, 0, 0, 255)},  {"G", new Color32(0, 255, 0, 255)},
        {"B", new Color32(0, 0, 255, 255)},  {"W", new Color32(255, 255, 255, 255)}
    };
    public static int brushSize = 3;// 上記色のこのサイズのい矩形をブラシとして使う
    RawImage rawImage;// Canvasいっぱいに配置したRawImageで、ここが描画対象

    void Start()
    {
        Time.fixedDeltaTime = 0.02f;//Application.targetFrameRate = 50;に相当する固定フレーム間隔指定

        TcpAppServer.onGUI_width = 300;//OnGUI()で使う表示幅変更
        TcpAppServer.onGUI_TextFieldFontSize = 24;//OnGUI()で使う表示文字サイス変更
        TcpAppServer.onGUI_LabelFontSize = 14;
        TcpAppServer.onGUI_ButtonFontSize = 16;
        TcpAppServer.onGUI_flagClient = false;// サーバ専用のonGUI操作画面を使う(クライアントが不要)

         TcpCommunication.tcp_port = 51234;// サーバで使うポート番号(実行後に変更可能)

        TcpAppServer.onTcpServerStart += (server) =>
        {// サーバ起動時の処理
            TcpAppServer.onGUI_flagServer = false;// サーバ用のOnGUI表示を消す
        };

        TcpAppServer.onAcceptTcp += (client) =>
        {// 接続してきたクライアントへのACCSEPT時の処理
            //TcpAppServer.onGUI_flag = false;// trueで接続時のデバック情報表示制御
        };

        TcpAppServer.onReceive += (TcpCommunication com, BinPacket msg) =>
        {   // クライアントからのの受信処理(この例では、msgの文字列を受信)
            string[] datas = msg.ToString().ToUpper().Split(",");// 受信例 "RED,100,20"で、色とxとyの座標
            if (datas.Length != 3)
            {
                TcpAppServer.onGUI_message = $"onReceiveの受信文字列エラー:{msg.ToString()}";
                return;
            }
            Color32 color = new Color32(0, 0, 0, 255);// 描画 黒
            string colorkey = datas[0].Length > 0 ? datas[0].Substring(0,1) : "X";
            if (colorMap.ContainsKey(colorkey)) color = colorMap[colorkey];//受信先頭文字から色を取得
            int x = int.Parse(datas[1]);
            int y = int.Parse(datas[2]);
            draw(color,  x,  y);
        };

        GameObject rawImageObj = GameObject.Find("RawImage");
        this.rawImage = rawImageObj.GetComponent<RawImage>();// 描画対象のRawImageをインスタンス変数にセット
        Color32 color = new Color32(255, 255, 255, 255);
        this.rawImage.texture = createTexture(width, height, color);// color色のTexture2Dを生成;

        // filePathのファイルが存在すれば、そのファイル内容で初期表示
        FileInfo fileInfo = new FileInfo(filePath);
        if (false && fileInfo.Exists)
        {
            byte[] buff = new byte[fileInfo.Length];// ファイル内容を記憶するバッファ
            using (FileStream stream = File.Open(filePath, FileMode.Open))
            {
                int idx = 0;
                while (idx < buff.Length)
                {
                    idx += stream.Read(buff, idx, buff.Length - idx);// ファイルから読み込む
                }
            }
            Texture2D texture = (Texture2D)this.rawImage.texture;
            Color32[] pixels = texture.GetPixels32();
            BytesToColor32(buff, pixels);// texture内のPixels32配列にbuffをセット
            texture.SetPixels32(pixels);// 画面更新
            texture.Apply();
        }
    }

    void FixedUpdate()
    {
        if (Input.GetMouseButton(1))// 右ボタン操作で消去
        {
            this.rawImage.texture = createTexture(width, height, new Color32(255, 255, 255, 255));
        }

        bool sendTiming = Time.time >= this.serverSendNextTiming;
        if (sendTiming == false) return;// クライアントへのイメージ送信タイミング制御
        this.serverSendNextTiming = Time.time + 0.01f; // 10ミリ秒ごとに送信(次の送信タイミング)

        // サーバーの画面の送信情報を取得
        Texture2D texture = (Texture2D)this.rawImage.texture;
        Color32[] pixels = texture.GetPixels32();
        byte[] sendBuffer = getBytesByPixels(pixels);

        // 接続してきたクライアントすべてに送信
        for (int i = 0; i < TcpAppServer.instance.clientlist.Count; i++)
        {
            TcpCommunication com = TcpAppServer.instance.clientlist[i];
            if (com == null || com.isRecRunning == false) continue;
            com.SendMessage(sendBuffer); // クライアントに送信
        }
    }

    // this.rawImage.textureに、color色で( x,  y )位置にbrushSize幅の正方形を描画
    private void draw(Color32 color, int x, int y)
    {
        int idx = y * height + x;// クリック位置からTexture2D内のピクセルを指定色に変更する。
        Texture2D texture = (Texture2D)this.rawImage.texture;
        Color32[] pixels = texture.GetPixels32();
        try
        {
            for (int y2 = 0; y2 < brushSize; y2++)
            {
                for (int x2 = 0; x2 < brushSize; x2++)
                {
                    pixels[idx + x2 + y2 * width] = color;
                }
            }
        }
        catch (Exception _) { }
        texture.SetPixels32(pixels);
        texture.Apply();
    }


    public void OnApplicationQuit()// プログラム終了時に、SerializeStrVector3オブジェクトをファイル化
    {
        Texture2D texture = (Texture2D)this.rawImage.texture;
        byte[] buff = getBytesByPixels(texture.GetPixels32());
        using (FileStream stream = File.Open(filePath, FileMode.Create))
        {
            stream.Write(buff, 0, buff.Length);// ファイルに書き込み
        }
    }

    // // color色で、width × heightのサイズのTexture2Dを生成する。
    public static Texture2D createTexture(int width, int height, Color32 color)
    {
        Texture2D texture = new Texture2D(width, height, TextureFormat.RGBA32, false);
        NativeArray<Color32> pixels = texture.GetRawTextureData<Color32>();
        Debug.Log($"{pixels.Length}  ( {texture.height}, {texture.width})");
        for (int i = 0; i < pixels.Length; i++)
        {
            pixels[i] = color;
        }
        texture.Apply();
        return texture;
    }

    // byte配列で、Texture2Dから取得したpixels配列を設定する(バイナリから画像を設定)
    public static void BytesToColor32(byte[] bytes, Color32[] pixels)
    {
        bool chk = bytes.Length == pixels.Length * 4;
        if (chk == false) throw new Exception("BytesToColor32: {bytes.Length]byteをpixelsに設定出来ません。");
        for (int idx_bytes = 0, idx = 0; idx < pixels.Length; idx++)
        {
            byte r = bytes[idx_bytes++];
            byte g = bytes[idx_bytes++];
            byte b = bytes[idx_bytes++];
            byte a = bytes[idx_bytes++];
            pixels[idx] = new Color32(r, g, b, a);
        }
    }

    // Texture2Dから取得したpixels配列からbyte配列からを生成して返す。(画像のバイナリ化)
    public static byte[] getBytesByPixels(Color32[] pixels)
    {
        byte[] bytes = new byte[pixels.Length * 4];
        for (int i = 0, idx_bytes = 0; i < pixels.Length; i++)
        {
            bytes[idx_bytes++] = pixels[i].r;
            bytes[idx_bytes++] = pixels[i].g;
            bytes[idx_bytes++] = pixels[i].b;
            bytes[idx_bytes++] = pixels[i].a;
        }
        return bytes;
    }
}

クライアント側のソース(WhiteBoradClient.cs:Canvasにアタッチ)と概要説明

前述の「ネットワーク化を考慮したネットワーク無しの作品」のソースを変更・追加しています。
(追加箇所が   の部分です。)
マウスドラックのイベントで在られた座標と、色データから送信文字列(例:"Red,100,50")を作り、 それをサーバに送信します。
また、サーバーから送られてくる画像バイナリ情報で,RawImageのテクスチャを更新します。
クライアントでの受信処理は、
BinPacket tempPacket = QueueBuffer.GetBuff(tcpCommunication.queueBuffer);で 得られたtempPacketがnullでないなら、それが受信パケットということで、画像として使っています。
using System;
using Unity.Collections;
using UnityEngine;
using UnityEngine.UI;

public class WhiteBoradClient : MonoBehaviour
{
    TcpCommunication tcpCommunication = new TcpCommunication(); // クライアント用

    const int width = 360;// RawImageに設定するTexture2Dのサイズ
    const int height = 360;
    public static int brushSize = 3;// 上記色のこのサイズの矩形をブラシとして使う
    RawImage rawImage;// Canvasいっぱいに配置したRawImageで、ここが描画対象

    void Start()
    {
        Time.fixedDeltaTime = 0.02f;//Application.targetFrameRate = 50;に相当する固定フレーム間隔指定

        TcpAppServer.onGUI_width = 300;//OnGUI()で使う表示幅変更
        TcpAppServer.onGUI_TextFieldFontSize = 24;//OnGUI()で使う表示文字サイス変更
        TcpAppServer.onGUI_LabelFontSize = 14;
        TcpAppServer.onGUI_ButtonFontSize = 16;
        TcpAppServer.onGUI_flagServer = false;//onGUIサーバ起動部表示のみ消す
        TcpCommunication.onGUI_LabelFontSize = 16;

        TcpCommunication.clientId = "Red"; // 自由に使える情報で、今回は色指定に使った。
        TcpCommunication.target_IP = "192.168.0.110"; // デフォルト接続先IPアドレス(実行後に変更可能)
        TcpCommunication.tcp_port = 51234;// サーバで使うポート番号(実行後に変更可能)

        TcpCommunication.tcpClientButton += () => {// TcpAppServerの接続用GUIボタンで実行
            TcpAppServer.onGUI_flagClient = false;//onGUIクライアントの接続部表示のみ消す       
            tcpCommunication.ConnectTo();// TcpCommunication.target_IP,TcpCommunication.tcp_portに接続
        };

        tcpCommunication.onConnect += () => {
            TcpAppServer.onGUI_flag = true;// デバックをしないならflaseにすると良い。
            TcpCommunication.onGUI_message += "接続した!"; // デバック用
        };

        GameObject rawImageObj = GameObject.Find("RawImage");
        this.rawImage = rawImageObj.GetComponent<RawImage>();
        Color32 color = new Color32(255, 255, 255, 255);
        this.rawImage.texture = createTexture(width, height, color);// color色のTexture2Dを生成;
    }

    void FixedUpdate()
    {
        if (Input.GetMouseButton(0))// 左ボタン操作
        {
            // 色と、クリック情報の送信文字列を生成して、送信
            Vector3 pos = Input.mousePosition;
            Vector3 viewPos = Camera.main.ScreenToViewportPoint(pos);
            int x = (int)(viewPos.x * width);
            int y = (int)(viewPos.y * height);
            Debug.Log($"viewPos={viewPos}x ={x:000}y={y:000}");
            string sendMsg = $"{TcpCommunication.clientId},{x},{y}";
            this.tcpCommunication.SendMessage(sendMsg); // サーバに送信
        }

        // クライアント側の受信処理(受信した最後のパケットを取り出す。途中のパケットが無視できる場合)
        BinPacket binPacket = null;
        for (;;)
        {
            // 受信情報の取り出し
            BinPacket tempPacket = QueueBuffer.GetBuff(tcpCommunication.queueBuffer);
            if (tempPacket == null) break;
            binPacket = tempPacket;
        }
        if (binPacket == null) return; // 受信パケット無し

        // 受信パケットで、rawImage.textureを更新
        byte[] bytes = BinPacket.getData(binPacket.packet);//受信パケットから、内容のバイナリを取り出だす。

        Texture2D texture = (Texture2D)this.rawImage.texture;
        Color32[] pixels = texture.GetPixels32();
        BytesToColor32(bytes,pixels);
        texture.SetPixels32(pixels);// 受信データで画面を更新
        texture.Apply();
    }

    private void OnGUI()
    {
        TcpCommunication.OnGUI_Sub();// TcpCommunication.messgae の表示(デバック用)
    }

    public static Texture2D createTexture(int width, int height, Color32 color)
    {
        Texture2D texture = new Texture2D(width, height, TextureFormat.RGBA32, false);
        NativeArray<Color32> pixels = texture.GetRawTextureData<Color32>();
        Debug.Log($"{pixels.Length}  ( {texture.height}, {texture.width})");
        for (int i = 0; i < pixels.Length; i++)
        {
            pixels[i] = color;
        }
        texture.Apply();
        return texture;
    }

    // byte配列からpixels配列を設定
    public static void BytesToColor32(byte[] bytes, Color32[] pixels)
    {
        bool chk = bytes.Length == pixels.Length * 4;
        if (chk == false) throw new Exception("BytesToColor32: {bytes.Length]byteをpixelsに設定出来ません。");
        for (int idx_bytes = 0, idx = 0; idx < pixels.Length; idx++)
        {
            byte r = bytes[idx_bytes++];
            byte g = bytes[idx_bytes++];
            byte b = bytes[idx_bytes++];
            byte a = bytes[idx_bytes++];
            pixels[idx] = new Color32(r, g, b, a);
        }
    }
}