上篇博客是在VIsual Studio2019实现了UDP通信,基于上篇博客博主在Unity中又进行了UDP实时推送视频流的实现

博主共使用一个服务器端工程场景和三个客户端工程场景进行通信模拟

三个客户端分别向服务端发送各自每帧的相机画面, 需要注意的是三个客户端不能同时向客户端发送视频流,这样服务器压力太大,容易卡死, 所以同一时刻只能有一路流向客户端发送消息, 例如:客户端1向服务端发送数据时,其他两个客户端得停止发送; 客户端2向服务端发送数据时,客户端1和3得停止发送, 服务器端同一时刻下只能展示一个客户端推过来的视频流

博主代码运行平台: Unity2020.3.37f1c1      Visual Studio 2019   

本项目场景布局简单,对Unity版本和VS要求不高

使用方法:

1.先启动服务器端工程,点击"Start"按钮,启动服务器

2.启动一个客户端,点击"Connection"按钮

3.再点击服务器端对应端口号按钮,例如:"8881端口",即可在服务器端看到对应端口号客户端得相机画面

  • Server端场景布局如下:

unity videoplayer 视频的长度 unity播放视频流_客户端

  • Server端代码:
/*服务端代码
 * 通讯方式:UDP
 * Author:WangYadong
 * Date:2024/04/25
 */
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using System;
using System.Text;
using System.Threading;
using System.Net;
using System.Net.Sockets;
using UnityEngine.Video;
using System.IO;
using System.IO.Compression;

public class ReceiveVideo : MonoBehaviour
{
    public RawImage rawImg;                                  //播放画面
    public Text ipPointTxt;                                 //当前正在播放画面的客户端IP和端口号
    private Texture2D tex2D;
    public Button btn_8881;
    public Button btn_8882;
    public Button btn_8883;
    public Text showClientCountTxt;                         //显示在线的客户端IP和端口号

    private string IP;                                      //IP地址
    private string Port;                                    //端口号
    private Socket _SocketServer;                           //服务端Socket
    private IPEndPoint endPoint;
    private Dictionary<string, EndPoint> _DicEndPoint = new Dictionary<string, EndPoint>();     //保存"客户端"Socket
    private StringBuilder sb_EndPoint = new StringBuilder();//所有在线的客户端
    

    // Start is called before the first frame update
    void Start()
    {
        //制定服务器IP和端口号
        IP = "192.168.31.21";
        //IP = "192.168.31.164";
        Port = "9090";       //这里分配服务器端口号为"9090"(当然也可以制定其他端口)

        tex2D = new Texture2D(640, 480);
        rawImg.texture = tex2D; 
    }

    public void EnableServerReady()
    {
        //需要绑定的地址和端口号
        endPoint = new IPEndPoint(IPAddress.Parse(IP), Convert.ToInt32(Port));
        //定义监听Socket
        _SocketServer = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
        //绑定
        _SocketServer.Bind(endPoint);

        //直接启用一个线程,Unity中的控件或函数无法使用;使用Loom可以在其他线程中直接使用Unity中的控件或函数,配合下面的Loom.QueueOnMainThread一起使用
        Loom.RunAsync(
           () =>
           {
               Thread thClientCon = new Thread(ReceiveMsg);
               thClientCon.IsBackground = true; //后台线程,这里之所以设置为后台线程主要是因为Unity中有主线程了,所以不再需要开启一个前台线程了
               thClientCon.Name = "thReceiveMsg";
               thClientCon.Start();
           }
           );
        Debug.Log("服务器已启动....");
       
    }

    /// <summary>
    /// 点击8881端口的客户端按钮,令其发送过来数据,其他客户端停止数据传输
    /// </summary>
    public void Btn8881Click()
    {
        foreach (var item in _DicEndPoint)
        {
            /* 服务器同一时刻只接收一个客户端的数据,并进行显示,不能同时接收多路客户端数据流,不然服务端压力较大,会卡死
             * 所以同意一个客户端发送数据的同时,其他客户端停止发送(但保持连接状态)
             */
            if (item.Key == "192.168.31.21:8881")
            {
                byte[] byte_on = Encoding.UTF8.GetBytes("on"); //向"8881"端口的客户端发送"on"命令,令其发送数据过来
                string[] strArray = item.Key.Split(':');
                EndPoint ep = new IPEndPoint(IPAddress.Parse(strArray[0]),Convert.ToInt32(strArray[1]));
                _SocketServer.SendTo(byte_on, ep);
            }
            else
            {
                byte[] byte_off = Encoding.UTF8.GetBytes("off"); //向其他端口客户端发送"off"命令,禁止其发送数据过来
                string[] strArray = item.Key.Split(':');
                EndPoint ep = new IPEndPoint(IPAddress.Parse(strArray[0]), Convert.ToInt32(strArray[1]));
                _SocketServer.SendTo(byte_off, ep);
            }
        }
    }

    /// <summary>
    /// 点击8882端口的客户端按钮,令其发送过来数据,其他客户端停止数据传输
    /// </summary>
    public void Btn8882Click()
    {
        foreach (var item in _DicEndPoint)
        {
            if (item.Key == "192.168.31.21:8882")
            {
                byte[] byte_on = Encoding.UTF8.GetBytes("on");
                string[] strArray = item.Key.Split(':');
                EndPoint ep = new IPEndPoint(IPAddress.Parse(strArray[0]), Convert.ToInt32(strArray[1]));
                _SocketServer.SendTo(byte_on, ep);
            }
            else
            {
                byte[] byte_off = Encoding.UTF8.GetBytes("off");
                string[] strArray = item.Key.Split(':');
                EndPoint ep = new IPEndPoint(IPAddress.Parse(strArray[0]), Convert.ToInt32(strArray[1]));
                _SocketServer.SendTo(byte_off, ep);
            }
        }
    }

    /// <summary>
    /// 点击8883端口的客户端按钮,令其发送过来数据,其他客户端停止数据传输
    /// </summary>
    public void Btn8883Click()
    {
        foreach (var item in _DicEndPoint)
        {
            if (item.Key == "192.168.31.21:8883")
            {
                byte[] byte_on = Encoding.UTF8.GetBytes("on");
                string[] strArray = item.Key.Split(':');
                EndPoint ep = new IPEndPoint(IPAddress.Parse(strArray[0]), Convert.ToInt32(strArray[1]));
                _SocketServer.SendTo(byte_on, ep);
            }
            else
            {
                byte[] byte_off = Encoding.UTF8.GetBytes("off");
                string[] strArray = item.Key.Split(':');
                EndPoint ep = new IPEndPoint(IPAddress.Parse(strArray[0]), Convert.ToInt32(strArray[1]));
                _SocketServer.SendTo(byte_off, ep);
            }
        }
    }

    byte[] decompressedData;
    /// <summary>
    /// (后台线程)接收客户端会话
    /// </summary>
    /// <param name="sockMsg">客户端会话</param>
    private void ReceiveMsg()
    {
        EndPoint ep = (EndPoint)endPoint;
        try
        {
            while (true)
            {
                //准备接收“数据缓存”
                byte[] msgArray = new byte[1024 * 1024]; //定义一个1M的数据缓存空间
                //获取客户端数据的真实长度                
                int trueClientMsgLength = _SocketServer.ReceiveFrom(msgArray, ref ep);
                //将每个客户端存入字典,便于管理
                if (!_DicEndPoint.ContainsKey(ep.ToString()))
                {
                    _DicEndPoint.Add(ep.ToString(), ep);
                    //当前连接上的客户端
                    sb_EndPoint.Append(ep.ToString() + "\r\n");                    
                }

                byte[] trueLengthBytes = new byte[trueClientMsgLength];
                Buffer.BlockCopy(msgArray,0, trueLengthBytes, 0, trueLengthBytes.Length); //获取真实长度的数据
                decompressedData = Decompress(trueLengthBytes); //解压

                //这里可以在其他线程中使用Unity的控件或函数
                Loom.QueueOnMainThread((param) =>
                {
                    //显示当前连接上的客户端
                    showClientCountTxt.text = sb_EndPoint.ToString();
                    //显示客户端画面
                    ipPointTxt.text = ep.ToString();
                    tex2D.LoadImage(decompressedData);
                    rawImg.texture = tex2D;

                }, null);
            }
        }
        catch (Exception e)
        {

            Debug.LogError("接收客户端消息出错了!!!!");
            Debug.LogError(e.Message);
        }
    }

    // <summary>
    /// 压缩数据
    /// </summary>
    /// <param name="noCompressDatas">未压缩的数据</param>
    /// <returns></returns>
    byte[] Compress(byte[] noCompressDatas)
    {
        MemoryStream meStream = new MemoryStream();
        using (GZipStream gZipStream = new GZipStream(meStream, CompressionMode.Compress))
        {
            gZipStream.Write(noCompressDatas, 0, noCompressDatas.Length);
        }
        return meStream.ToArray();
    }

    /// <summary>
    /// 解压数据
    /// </summary>
    /// <param name="compressedData">已压缩的数据</param>
    /// <returns></returns>
    byte[] Decompress(byte[] compressedData)
    {
        using (MemoryStream memoryStream = new MemoryStream(compressedData))
        {
            using (GZipStream gZipStream = new GZipStream(memoryStream, CompressionMode.Decompress))
            {
                using (MemoryStream tempMeoryStream = new MemoryStream())
                {
                    gZipStream.CopyTo(tempMeoryStream);
                    return tempMeoryStream.ToArray();
                }
            }
        }
    }

    /// <summary>
    /// 关闭并清理连接资源
    /// </summary>
    private void OnApplicationQuit()
    {
        try
        {
            _SocketServer.Shutdown(SocketShutdown.Both);    //关闭socket连接
            _SocketServer.Close();                          //清理Socket资源
        }
        catch (Exception e)
        {
            Debug.LogError(e.Message);
        }
    }

    /// <summary>
    /// 关闭并清理连接资源
    /// </summary>
    private void OnDestroy()
    {
        try
        {
            _SocketServer.Shutdown(SocketShutdown.Both);    //关闭socket连接
            _SocketServer.Close();                          //清理Socket资源
        }
        catch (Exception e)
        {
            Debug.LogError(e.Message);
        }
    }
}
  • 客户端1场景布局如下: 

 

unity videoplayer 视频的长度 unity播放视频流_udp_02

unity videoplayer 视频的长度 unity播放视频流_System_03

unity videoplayer 视频的长度 unity播放视频流_网络协议_04

  • 客户端1代码如下: 

 SendVideo代码:

/* 客户端代码
 * 通讯方式:UDP
 * Author:WangYadong 
 * Date:2024/04/25
 */
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using System.Text;
using System.Threading;
using System.Net;
using System.Net.Sockets;
using System;
using System.IO;
using System.IO.Compression;

public class SendVideo : MonoBehaviour
{
    private string ClientIP;                                //客户端IP
    private string ServerIP;                                //服务端IP
    private string ClientPort;                              //客户端端口
    private string ServerPort;                              //服务端端口
    private Socket _SocketClient;                           //客户端Socket
    private IPEndPoint endPoint;
    private RenderTexture cameraView = null;
    public Camera cam;                                       //客户端呈现画面的相机
    private bool isSendData = false;                        //是否发送数据
    private EndPoint serverEnd;                             //服务端EndPoint
    Texture2D screenshot = null;                            //抓取摄像机画面

    // Start is called before the first frame update
    void Start()
    {
        //指定客户端IP和端口号
        ClientIP = "192.168.31.21";                         //这里分配本客户端为8882端口
        ClientPort = "8881";                                //服务器IP
        ServerIP = "192.168.31.21";
        //ServerIP = "192.168.31.164";
        ServerPort = "9090";                                //服务器端口

        cameraView = new RenderTexture(640, 480, 24);
        cameraView.enableRandomWrite = true;
        cam.targetTexture = cameraView;
        screenshot = new Texture2D(640, 480, TextureFormat.RGB24, false);       
        
    }

    /// <summary>
    /// 启动客户端连接
    /// </summary>
    public void EnableClientCon()
    {
        //通讯IP与端口号
        endPoint = new IPEndPoint(IPAddress.Parse(ClientIP), Convert.ToInt32(ClientPort));
        //建立客户端连接
        _SocketClient = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
        //绑定IP和端口号
        _SocketClient.Bind(endPoint);

        try
        {
            IPEndPoint remoteEP = new IPEndPoint(IPAddress.Parse(ServerIP),Convert.ToInt32(ServerPort));
            serverEnd = (EndPoint)remoteEP;
            _SocketClient.Connect((EndPoint)serverEnd);     //连接服务端
            _SocketClient.SendTo(Encoding.UTF8.GetBytes("1"), (EndPoint)serverEnd); //向服务端发送一条测试消息,以便客户端记到字典里
            Debug.Log("连接服务器成功!");

            //直接启用一个线程,Unity中的控件或函数无法使用;使用Loom可以在其他线程中直接使用Unity中的控件或函数,配合下面的Loom.QueueOnMainThread一起使用
            Loom.RunAsync(() =>
            {
                Thread thClientCon = new Thread(StartRecvMessage);
                thClientCon.IsBackground = true; //后台线程,这里之所以设置为后台线程主要是因为Unity中有主线程了,所以不再需要开启一个前台线程了
                thClientCon.Name = "thReceiveMsg";
                thClientCon.Start();
            });
        }
        catch (Exception e)
        {
            Debug.LogError("连接客户端失败,请检查!");
            Debug.LogError(e.Message);
        }
        
    }

    byte[] bytes;          //画面数据流
    byte[] compressBytes; //压缩后的数据
    /// <summary>
    /// 向服务器端发送消息
    /// </summary>
    public void SendMMsg()
    {
        try
        {
            //抓取客户端画面数据流
            RenderTexture.active = cameraView;
            screenshot.ReadPixels(new Rect(0, 0, 640, 480), 0, 0);
            RenderTexture.active = null;
            bytes = screenshot.EncodeToJPG(100);
            //_SocketClient.SendTo(bytes, serverEnd);

            //数据流压缩,提高传输效率
            compressBytes = Compress(bytes);
            //发送给服务端
            _SocketClient.SendTo(compressBytes, serverEnd);

        }
        catch (Exception e)
        {
            print("Erro: " + e.Message);
        }

    }

    /// <summary>
    /// 接收服务端消息
    /// </summary>
    private void StartRecvMessage()
    {
        try
        {
            //这里用无限循环持续接收服务端消息
            while (true)
            {
                byte[] buffer = new byte[1024 * 1024];  //定义一个1M数据缓存控件
                int trueClientMsgLength = _SocketClient.ReceiveFrom(buffer, ref serverEnd); //接收服务端消息
                string recMsg = Encoding.UTF8.GetString(buffer, 0, trueClientMsgLength);   //转为字符串

                //服务端命令,off:禁止客户端向其发送消息,on:开始向其发送消息
                if (recMsg == "off")
                {
                    isSendData = false;
                    //break;
                }
                else if (recMsg=="on")
                {
                    isSendData = true;
                }
            }
        }
        catch (Exception e)
        {
            Debug.LogError("Error: " + e.Message);
        }

    }

    private void Update()
    {
        if (isSendData)
        {
            try
            {
                SendMMsg();   //向服务端发送消息
            }
            catch (Exception e)
            {

                Debug.Log(e.Message);
            }
        }

    }

    /// <summary>
    /// 关闭并清理连接资源
    /// </summary>
    private void OnApplicationQuit()
    {
        try
        {
            _SocketClient.Shutdown(SocketShutdown.Both);    //关闭socket连接
            _SocketClient.Close();                          //清理Socket资源
        }
        catch (Exception e)
        {

            Debug.Log("OnApplicationQuit: " + e.Message);
        }
    }

    /// <summary>
    /// 关闭并清理连接资源
    /// </summary>
    private void OnDestroy()
    {
        try
        {
            _SocketClient.Shutdown(SocketShutdown.Both);
        }
        catch (Exception e)
        {

            Debug.Log("OnDestroy: " + e.Message);
        }
    }

    // <summary>
    /// 压缩
    /// </summary>
    /// <param name="noCompressDatas">未压缩的数据</param>
    /// <returns></returns>
    byte[] Compress(byte[] noCompressDatas)
    {
        MemoryStream meStream = new MemoryStream();
        using (GZipStream gZipStream = new GZipStream(meStream, CompressionMode.Compress))
        {
            gZipStream.Write(noCompressDatas, 0, noCompressDatas.Length);
        }
        return meStream.ToArray();
    }

    /// <summary>
    /// 解压
    /// </summary>
    /// <param name="compressedData">已压缩的数据</param>
    /// <returns></returns>
    byte[] Decompress(byte[] compressedData)
    {
        using (MemoryStream memoryStream = new MemoryStream(compressedData))
        {
            using (GZipStream gZipStream = new GZipStream(memoryStream, CompressionMode.Decompress))
            {
                using (MemoryStream tempMeoryStream = new MemoryStream())
                {
                    gZipStream.CopyTo(tempMeoryStream);
                    return tempMeoryStream.ToArray();
                }
            }
        }
    }
}

CubeTranslate代码:

这里只是为了让客户端场景里有一个动的物体而已,所以只简单放了一个立方体持续转动

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class CubeTranslate : MonoBehaviour
{
    // Update is called once per frame
    void Update()
    {
        gameObject.transform.Rotate(0, 100 * Time.deltaTime, 0);
    }
}
  • 客户端2场景和客户端3场景,布局和客户端1场景布局一致,只是将 场景里的Cube换成了Cylinder及材质变动, 代码基本一致, 只有端口号变了, 这里不再赘述

客户端2: 

 

unity videoplayer 视频的长度 unity播放视频流_System_05

客户端3: 

unity videoplayer 视频的长度 unity播放视频流_网络_06

另外本项目中还涉及到Loom多线程的使用, 大家都知道Unity中直接开辟一个线程后,在该线程里无法再使用Unity自带的类和方法及控件,所以要借助Loom开辟线程 (Loom.RunAsync()可开辟线程),再配合Loom中的Loom.QueueOnMainThread()方法就可以直接使用Unity自带的类,方法和控件了

Loom代码如下:

using UnityEngine;
using System.Collections;
using System.Collections.Generic;
using System;
using System.Threading;
using System.Linq;

public class Loom : MonoBehaviour
{
    public static int maxThreads = 8;
    static int numThreads;

    private static Loom _current;
    //private int _count;
    public static Loom Current
    {
        get
        {
            Initialize();
            return _current;
        }
    }

    //用于初始化一次,在程序入口调用一次
    public void StartUp()
    { }

    static bool initialized;

    public static void Initialize()
    {
        if (!initialized)
        {

            if (!Application.isPlaying)
                return;
            initialized = true;
            var g = new GameObject("Loom");
            _current = g.AddComponent<Loom>();
#if !ARTIST_BUILD
            UnityEngine.Object.DontDestroyOnLoad(g);

#endif
        }

    }
    public struct NoDelayedQueueItem
    {
        public Action<object> action;
        public object param;
    }

    private List<NoDelayedQueueItem> _actions = new List<NoDelayedQueueItem>();
    public struct DelayedQueueItem
    {
        public float time;
        public Action<object> action;
        public object param;
    }
    private List<DelayedQueueItem> _delayed = new List<DelayedQueueItem>();

    List<DelayedQueueItem> _currentDelayed = new List<DelayedQueueItem>();

    public static void QueueOnMainThread(Action<object> taction, object tparam)
    {
        QueueOnMainThread(taction, tparam, 0f);
    }
    public static void QueueOnMainThread(Action<object> taction, object tparam, float time)
    {
        if (time != 0)
        {
            lock (Current._delayed)
            {
                Current._delayed.Add(new DelayedQueueItem { time = Time.time + time, action = taction, param = tparam });
            }
        }
        else
        {
            lock (Current._actions)
            {
                Current._actions.Add(new NoDelayedQueueItem { action = taction, param = tparam });
            }
        }
    }

    public static Thread RunAsync(Action a)
    {
        Initialize();
        while (numThreads >= maxThreads)
        {
            Thread.Sleep(100);
        }
        Interlocked.Increment(ref numThreads);
        ThreadPool.QueueUserWorkItem(RunAction, a);
        return null;
    }

    private static void RunAction(object action)
    {
        try
        {
            ((Action)action)();
        }
        catch
        {
        }
        finally
        {
            Interlocked.Decrement(ref numThreads);
        }

    }


    void OnDisable()
    {
        if (_current == this)
        {

            _current = null;
        }
    }

    List<NoDelayedQueueItem> _currentActions = new List<NoDelayedQueueItem>();

    // Update is called once per frame
    void Update()
    {
        if (_actions.Count > 0)
        {
            lock (_actions)
            {
                _currentActions.Clear();
                _currentActions.AddRange(_actions);
                _actions.Clear();
            }
            for (int i = 0; i < _currentActions.Count; i++)
            {
                _currentActions[i].action(_currentActions[i].param);
            }
        }

        if (_delayed.Count > 0)
        {
            lock (_delayed)
            {
                _currentDelayed.Clear();
                _currentDelayed.AddRange(_delayed.Where(d => d.time <= Time.time));
                for (int i = 0; i < _currentDelayed.Count; i++)
                {
                    _delayed.Remove(_currentDelayed[i]);
                }
            }

            for (int i = 0; i < _currentDelayed.Count; i++)
            {
                _currentDelayed[i].action(_currentDelayed[i].param);
            }
        }
    }
}