【游戏开发实战】Unity从零开发多人视频聊天功能,无聊了就和自己视频聊天(附源码 | Mirror | 多人视频 | 详细教程)

请添加图片描述
请添加图片描述
请添加图片描述

一、前言

嗨,大家好,我是新发。
事情是这样的,我前几天写了一篇《【游戏开发实战】Unity使用Socket通信实现简单的多人聊天室(万字详解 | 网络 | TCP | 通信 | Mirror | Networking)》
有同学留言问我多人在线视频聊天切换清晰度怎么做,
在这里插入图片描述
嗯,作为一位热心的技术博主,我一般都是能帮则帮。
嘛,今天就来写个多人视频聊天的功能吧(并且可以切换清晰度)。

二、思考问题与解决方案

1、思考问题

多人视频聊天大家应该都不陌生,像腾讯视频会议那样,多个人的视频画面同时显示在界面中。

我的实现思路是每个客户端对本地摄像头画面进行采样,得到帧图像,然后对图像进行适当的压缩,转为字节流上传给服务端,接着服务端根据每个客户端设置的清晰度对帧图像进行压缩,然后转发帧图像给其他客户端,其他客户端接收到帧图像字节流后进行解码,最后显示到界面中。
画成图是这样子:

在这里插入图片描述
要实现上面的功能,我们需要先思考并解决以下几个必要问题:
1 Unity中如何开启摄像头并对图像进行采样?
2 图像如何中转给其他客户端?
3 如何实现清晰度切换?
4 客户端如何对图像进行解码并显示?

2、解决方案

2.1、Unity中如何开启摄像头并对图像进行采样

Unity提供了WebCamTexture这个类,通过它我们可以很方便的访问摄像头图像。
具体用法我下文会讲。
在这里插入图片描述

2.2、图像如何中转给其他客户端

正常情况我们需要搭建一个中转的服务端,要实现数据的序列化、网络通信、数据的反序列化等。这里我想使用Mirror网络库来实现。在之前那篇文章中我也有介绍过Mirror《【游戏开发实战】Unity使用Socket通信实现简单的多人聊天室(万字详解 | 网络 | TCP | 通信 | Mirror | Networking)》

在这里插入图片描述
在这里插入图片描述

2.3、如何实现清晰度切换

客户端把清晰度设置告诉服务端,服务端根据清晰度对图像进行压缩,把压缩后的图像下发给客户端。
由于我们是使用Mirror,服务端也是Unity客户端,所以我们可以直接使用Texture2DEncodeToJPG接口对图像进行压缩,第二个参数就是压缩率,值从1~100(默认是75),

扫描二维码关注公众号,回复: 14627634 查看本文章
public static byte[] EncodeToJPG(this Texture2D tex, int quality);
2.4、客户端如何对图像进行解码并显示

通过网络传输过来的图像是字节流,我们需要把它反序列化为Unity可现实的图像Texture2D,我们直接使用Texture2DLoadImage接口,

public static bool LoadImage(this Texture2D tex, byte[] data);

三、实际操作

下面,撸起袖子开始动手实际操作吧~

0、思维导图

养成好习惯,动手前先画思维图,如下:
在这里插入图片描述

1、界面设计与制作

使用axure快速原型设计工具先简单设计一下界面,
登录界面,Host就是作为房主,Client就是作为路人,
在这里插入图片描述
视频聊天界面,排列显示多个视频画面,可切换视频清晰度,可随时退出房间,
在这里插入图片描述

2、UI素材获取

简单的UI素材资源我是在阿里巴巴矢量图库上找,地址:https://www.iconfont.cn/
比如搜索按钮
在这里插入图片描述
找一个形状合适的,可以进行调色,我一般是调成白色,
在这里插入图片描述
因为Unity中可以设置Color,这样我们只需要一个白色按钮就可以在Unity中创建不同颜色的按钮了。
弄点基础的美术资源,
在这里插入图片描述
注:那个头像是我自己用PhotoShop画的哦,我之前用PhotoShop画过一幅原创连环画,如下:
在这里插入图片描述

3、创建Unity工程

我使用的Unity版本为2021.1.7f1c1 个人版,我们要做的是一个多人视频聊天的功能,不需要使用到3D相关的内容,所以我们创建工程时使用2D模板,工程名就叫UnityVideoChat吧~
在这里插入图片描述
创建成功,
在这里插入图片描述

4、制作UI界面

根据我们的原型设计,使用UGUI制作界面:MainPanel.prefab
在这里插入图片描述
如下,
在这里插入图片描述
在这里插入图片描述
其中用于渲染视频图像的UI独立做成一个预设:VideoImage.prefab,方便进行克隆(每连接一客户端就克隆一个VideoImage
在这里插入图片描述
如下,使用RawImage组件来显示图像,
在这里插入图片描述

5、下载Mirror网络插件

MirrorUnity Asset Store地址:
https://assetstore.unity.com/packages/tools/network/mirror-129321
在这里插入图片描述
Mirror插件添加到自己的账号中,然后回到Unity,在Package Manager中就可以下载了,
在这里插入图片描述
下载下来导入Unity中,
在这里插入图片描述

6、写C#代码

6.1、网络管理器:VideoChatNetwork.cs

先画个图,方便大家直观地知道VideoChatNetwork做什么:
在这里插入图片描述

注:VideoChatNetwork.cs脚本完整代码见我的文末的工程源码,下面只讲一些重点的地方。

创建VideoChatNetwork.cs脚本,它需要继承Mirror.NetworkManager

// VideoChatNetwork.cs

using Mirror;

public class VideoChatNetwork : NetworkManager
{
    
    
	// ...
}

启动服务端:

StartHost();

启动客户端:

StartClient();

关闭服务端:

StopHost();

关闭客户端:

StopClient();

定义消息CreatePlayerMessage(用于传递用户名):

public struct CreatePlayerMessage : NetworkMessage
{
    
    
    public string name;
}

服务器启动成功回调,注册CreatePlayerMessage消息响应函数,在响应函数中实例化Player并添加到NetworkServer中:

public override void OnStartServer()
{
    
    
	base.OnStartServer();
	// 注册事件
	NetworkServer.RegisterHandler<CreatePlayerMessage>(OnCreatePlayer);
	// ...
}

void OnCreatePlayer(NetworkConnection connection, CreatePlayerMessage createPlayerMessage)
{
    
    
	// 实例化Player
	GameObject playergo = Instantiate(playerPrefab);
    playergo.GetComponent<Player>().accountName = createPlayerMessage.name;
    // 添加Player
	NetworkServer.AddPlayerForConnection(connection, playergo);
}

客户端连接成功回调:

public override void OnClientConnect(NetworkConnection conn)
{
    
    
	base.OnClientConnect(conn);
	// 转发通知
	conn.Send(new CreatePlayerMessage {
    
     name = MainLogic.instance.accountName });
}

连接断开回调:

public override void OnClientDisconnect(NetworkConnection conn)
{
    
    
	// TODO 重新登录
}
6.2、摄像头画面:Player.cs

Player思维导图:
在这里插入图片描述

注:Player.cs脚本完整代码见我的文末的工程源码,下面只讲一些重点的地方。

创建Player.cs脚本,它需要继承Mirror.NetworkBehaviour

using Mirror;

public class Player : NetworkBehaviour
{
    
    
	// ...
}

先定义一些必要的UI对象,其中用户名使用[SyncVar]注解进行自动同步,

public RawImage videoImage;
[SyncVar]
public string accountName;
public Text accountNameText;

Start函数中判断是否是本地用户isLocalPlayer,如果是,则开启摄像头:

// Player.cs

private WebCamTexture webCam;

private void Start()
{
    
    
	 if (isLocalPlayer)
     {
    
    
          // 开启摄像头
          WebCamDevice[] devices = WebCamTexture.devices;
          webCam = new WebCamTexture(devices[0].name, 128, 128, 5);  //设置宽、高和帧率   
          webCam.Play();
      }
      // ...
}

Update函数中对摄像头图像进行采样,0.3秒采集一帧,可适当进行调整,同时把图像转为字节流并发送给服务端,

// Player.cs

private float timer;
 
public void Update()
{
    
    
     if (isLocalPlayer && null != webCam)
     {
    
    
         timer += Time.deltaTime;
         if (timer > 0.3f)
         {
    
    
             timer = 0;
             // 采样
             videoImage.texture = webCam;
			 // 图像转字节流	
             var bytes = MainLogic.instance.WebCamTextureToBytes(webCam);
             // 发送字节流给服务端
             CmdSendTextureBytes(bytes);
         }
     }
 }

发送图像字节流给服务端,注意Command为客户端远程调用服务端,

// 发送图像字节流给服务端
// Command为客户端远程调用服务端
[Command]
public void CmdSendTextureBytes(byte[] texture)
{
    
    
    RpcReceiveTexture(texture);
}

客户端接收服务端的图像字节流数据,并显示到RawImage上,注意ClientRpc为服务端远程调用客户端,

// 客户端接收服务端的图像字节流数据,并显示到RawImage上
// ClientRpc为服务端远程调用客户端
[ClientRpc]
public void RpcReceiveTexture(byte[] textureBytes)
{
    
    
    if(!isLocalPlayer)
    {
    
    
        // 压缩
        var compressedTex = MainLogic.instance.CompressTexture(textureBytes);
        // 显示
        videoImage.texture = MainLogic.instance.BytesToTexture2D(compressedTex);
    }    
}

上面我们出现了Mirror三个注解:[SyncVar][Command][ClientRpc],想要理解它们最好的办法是反编译我们的C#dll,看它生成的代码(使用ILSpy.exedll进行反编译)。

[SyncVar]
[SyncVar]会对我们的变量做自动同步(自动序列化、网络传递、反序列化),例:

[SyncVar]
public string accountName;

编译后生成的代码:

[SyncVar]
public string accountName;

public string NetworkaccountName
{
    
    
	get
	{
    
    
		return accountName;
	}
	[param: In]
	set
	{
    
    
		if (!SyncVarEqual(value, ref accountName))
		{
    
    
			string text = accountName;
			SetSyncVar(value, ref accountName, 1uL);
		}
	}
}

// 序列化
public override bool SerializeSyncVars(NetworkWriter writer, bool forceAll)
{
    
    
	bool result = base.SerializeSyncVars(writer, forceAll);
	if (forceAll)
	{
    
    
		writer.WriteString(accountName);
		return true;
	}
	writer.WriteULong(base.syncVarDirtyBits);
	if ((base.syncVarDirtyBits & 1L) != 0L)
	{
    
    
		writer.WriteString(accountName);
		result = true;
	}
	return result;
}

// 反序列化
public override void DeserializeSyncVars(NetworkReader reader, bool initialState)
{
    
    
	base.DeserializeSyncVars(reader, initialState);
	if (initialState)
	{
    
    
		string text = accountName;
		NetworkaccountName = reader.ReadString();
		return;
	}
	long num = (long)reader.ReadULong();
	if ((num & 1L) != 0L)
	{
    
    
		string text2 = accountName;
		NetworkaccountName = reader.ReadString();
	}
}

我们代码中对accountName的操作,都被替代为对NetworkaccountName的操作,比如:

playergo.GetComponent<Player>().accountName = createPlayerMessage.name;

变成了:

playergo.GetComponent<Player>().NetworkaccountName = createPlayerMessage.name;

[Command]
[Command]表示客户端远程调用服务端。
例:

[Command]
public void CmdSendTextureBytes(byte[] texture)
{
    
    
    RpcReceiveTexture(texture);
}

编译后生成的代码:

[Command]
public void CmdSendTextureBytes(byte[] texture)
{
    
    
	PooledNetworkWriter writer = NetworkWriterPool.GetWriter();
	writer.WriteBytesAndSize(texture);
	SendCommandInternal(typeof(Player), "CmdSendTextureBytes", writer, 0);
	NetworkWriterPool.Recycle(writer);
}

protected void UserCode_CmdSendTextureBytes(byte[] texture)
{
    
    
	RpcReceiveTexture(texture);
}

protected static void InvokeUserCode_CmdSendTextureBytes(NetworkBehaviour obj, NetworkReader reader, NetworkConnectionToClient senderConnection)
{
    
    
	if (!NetworkServer.active)
	{
    
    
		Debug.LogError((object)"Command CmdSendTextureBytes called on client.");
	}
	else
	{
    
    
		((Player)obj).UserCode_CmdSendTextureBytes(reader.ReadBytesAndSize());
	}
}

static Player()
{
    
    
	RemoteCallHelper.RegisterCommandDelegate(typeof(Player), 
		"CmdSendTextureBytes", InvokeUserCode_CmdSendTextureBytes, 
		requiresAuthority: true);
}

我们可以看到,它把我们的调用转成了网络消息,变量做了序列化、传递和反序列化。
[ClientRpc]
[ClientRpc]表示服务端远程调用客户端。
例:

[ClientRpc]
public void RpcReceiveTexture(byte[] textureBytes)
{
    
    
    if(!isLocalPlayer)
    {
    
    
        // 压缩
        var compressedTex = MainLogic.instance.CompressTexture(textureBytes);
        // 显示
        videoImage.texture = MainLogic.instance.BytesToTexture2D(compressedTex);
    }    
}

编译后生成的代码:

[ClientRpc]
public void RpcReceiveTexture(byte[] textureBytes)
{
    
    
	PooledNetworkWriter writer = NetworkWriterPool.GetWriter();
	writer.WriteBytesAndSize(textureBytes);
	SendRPCInternal(typeof(Player), "RpcReceiveTexture", writer, 0, includeOwner: true);
	NetworkWriterPool.Recycle(writer);
}

protected void UserCode_RpcReceiveTexture(byte[] textureBytes)
{
    
    
	if (!base.isLocalPlayer)
	{
    
    
		byte[] compressedTex = MainLogic.instance.CompressTexture(textureBytes);
		videoImage.texture = (Texture)(object)MainLogic.instance.BytesToTexture2D(compressedTex);
	}
}

protected static void InvokeUserCode_RpcReceiveTexture(NetworkBehaviour obj, NetworkReader reader, NetworkConnectionToClient senderConnection)
{
    
    
	if (!NetworkClient.active)
	{
    
    
		Debug.LogError((object)"RPC RpcReceiveTexture called on server.");
	}
	else
	{
    
    
		((Player)obj).UserCode_RpcReceiveTexture(reader.ReadBytesAndSize());
	}
}

static Player()
{
    
    
	RemoteCallHelper.RegisterRpcDelegate(typeof(Player), 
	"RpcReceiveTexture", InvokeUserCode_RpcReceiveTexture);
}

我们可以看到,它把我们的调用转成了网络消息,变量做了序列化、传递和反序列化。

6.3、业务逻辑:MainLogic.cs

MainLogic思维导图:
在这里插入图片描述

注:MainLogic.cs脚本完整代码见我的文末的工程源码,下面只讲一些重点的地方。

创建MainLogic.cs脚本,全局唯一一个实例对象,我们使用单例模式:

// MainLogic.cs

public class MainLogic
{
    
    
	// 单例模式
	private static MainLogic s_instance;
    public static MainLogic instance
    {
    
    
        get
        {
    
    
            if (null == s_instance)
                s_instance = new MainLogic();
            return s_instance;
        }
    }
}

定义成员变量:

/// <summary>
/// 用户名
/// </summary>
public string accountName;

/// <summary>
/// 清晰度,0:高清,1:标清,2:普通
/// </summary>
public int definition;

初始化,设置成员变量和回调函数:

private Action backToLoginCb;

/// <summary>
/// 初始化
/// </summary>
/// <param name="network">网络管理器</param>
/// <param name="backToLoginCb">回调登录界面的回调函数</param>
public void Init(VideoChatNetwork network, Action backToLoginCb)
{
    
    
    this.network = network;
    this.backToLoginCb = backToLoginCb;
}

public void OnClientDisconnect()
{
    
    
    if (null != backToLoginCb)
        backToLoginCb();
}

启动服务端,IP地址默认是localhost

/// <summary>
/// 启动服务端
/// </summary>
/// <param name="ip">IP地址</param>
/// <param name="account">用户名</param>
/// <param name="cb">成功回调函数</param>
public void StartHost(string ip, string account, Action cb)
{
    
    
    if (!NetworkClient.isConnected && !NetworkServer.active)
    {
    
    
        this.accountName = account;
        network.networkAddress = ip;

        network.DoStartHost(cb);
    }
}

启动客户端,IP地址默认是localhost

/// <summary>
/// 启动客户端
/// </summary>
/// <param name="ip">IP地址</param>
/// <param name="account">用户名</param>
/// <param name="cb">回调函数</param>
public void StartClient(string ip, string account, Action cb)
{
    
    
    this.accountName = account;
    network.networkAddress = ip;
    network.DoStartClient(cb);
}

关闭网络:

/// <summary>
/// 关闭网络
/// </summary>
public void Close()
{
    
    
    network.StopHost();
    network.StopClient();
}

切换清晰度:

/// <summary>
/// 切换清晰度
/// </summary>
public void SwitchDefinition(int v)
{
    
    
    definition = v;
}

/// <summary>
/// 根据图像清晰度进行图像压缩
/// </summary>
public byte[] CompressTexture(byte[] texture)
{
    
    
    if (null == texture) return null;

    switch (definition)
    {
    
    
        case 0:
        default:
            {
    
    
                return texture;
            }
        case 1:
            {
    
    
                var tex2D = MainLogic.instance.BytesToTexture2D(texture);
                return tex2D.EncodeToJPG(40);
            }
        case 2:
            {
    
    
                var tex2D = MainLogic.instance.BytesToTexture2D(texture);
                return tex2D.EncodeToJPG(10);
            }
    }
}

字节流转Texture2D

/// <summary>
/// 字节流转Texture2D
/// </summary>
/// <param name="textureBytes">图像字节流</param>
/// <returns></returns>
public Texture2D BytesToTexture2D(byte[] textureBytes)
{
    
    
    Texture2D tex2D = new Texture2D(30, 30);
    tex2D.LoadImage(textureBytes);
    return tex2D;
}

摄像头图像转字节流:

/// <summary>
/// 摄像头图像转字节流
/// </summary>
/// <param name="webCam">摄像头帧画面</param>
/// <param name="quality">压缩了,1~100,默认75</param>
/// <returns></returns>
public byte[] WebCamTextureToBytes(WebCamTexture webCam, int quality = 75)
{
    
    
    Texture2D texture = new Texture2D(webCam.width, webCam.height);
    int y = 0;
    while (y < texture.height)
    {
    
    
        int x = 0;
        while (x < texture.width)
        {
    
    
            Color color = webCam.GetPixel(x, y);
            texture.SetPixel(x, y, color);
            ++x;
        }
        ++y;
    }
    texture.Apply();


    var bytes = texture.EncodeToJPG(quality);
    return bytes;
}
6.4、界面交互:MainPanel.cs

MainPanel思维导图:
在这里插入图片描述

创建MainPanel.cs脚本,在MainPanel脚本中我们去写界面交互的代码,
先定义一些必要的UI成员,

// IP地址输入框
public InputField ipInput;
// 用户名输入框
public InputField accountInput;
// 房主按钮
public Button hostBtn;
// 房客按钮
public Button clientBtn;
// 清晰度下拉框
public Dropdown definitionDropdown;
// 登录UI父节点
public GameObject loginObj;
// 视频聊天UI父节点
public GameObject videoChatObj;
// 关闭网络按钮
public Button closeBtn;

Awake中进行初始化:

private void Awake()
{
    
    
    var networkObj = GameObject.Find("NetworkManager");
    MainLogic.instance.Init(networkObj.GetComponent<VideoChatNetwork>(), () =>
    {
    
    
        loginObj.SetActive(true);
        videoChatObj.SetActive(false);
    });
}

Start函数中设置各个按钮的响应逻辑:

void Start()
{
    
    
    // Host按钮
    hostBtn.onClick.AddListener(() =>
    {
    
    
        MainLogic.instance.StartHost(ipInput.text, accountInput.text, () =>
         {
    
    
             loginObj.SetActive(false);
             videoChatObj.SetActive(true);
         });
    });

    // Client按钮
    clientBtn.onClick.AddListener(() =>
    {
    
    
        MainLogic.instance.StartClient(ipInput.text, accountInput.text, () =>
         {
    
    
             loginObj.SetActive(false);
             videoChatObj.SetActive(true);
         });
    });

    // 关闭网络按钮
    closeBtn.onClick.AddListener(() =>
    {
    
    
        MainLogic.instance.Close();
        loginObj.SetActive(true);
        videoChatObj.SetActive(false);
    });

    // 分辨率下拉框
    definitionDropdown.onValueChanged.AddListener((v) => 
    {
    
    
        MainLogic.instance.SwitchDefinition(v);
    });
}

7、挂脚本

7.1、VideoChatNetwork脚本

场景中创建一个空物体,重命名为NetworkManager
在这里插入图片描述
在它身上挂上VideoChatNetwork脚本(它会自动挂上KcpTransport脚本),
在这里插入图片描述
VideoChatNetwork赋值Player Prefab,并去掉Auto Create Player的勾选,
在这里插入图片描述

KcpTransport使用的是可靠UDP传输,在KcpTransport组件上可以设置端口号,
在这里插入图片描述

7.2、Player脚本

VideoImage.prefab预设根节点挂上````Player脚本(它会自动挂上NetworkIdentity脚本),赋值Player脚本的UI```成员,
在这里插入图片描述

7.3、MainPanel脚本

MainPanel.prefab预设挂上MainPanel脚本,并赋值UI成员,
在这里插入图片描述

8、Editor环境下测试

Editor环境下运行,效果如下:
请添加图片描述

9、发布应用

9.1、发布Windows平台exe

转为PC平台,添加场景,
在这里插入图片描述
设置窗口尺寸为1280*720
在这里插入图片描述
执行Build,打出exe
在这里插入图片描述

9.2、发布Android平台apk

转为Android平台,添加场景,
在这里插入图片描述
在这里插入图片描述
设置包名为com.linxinfa.videochat
在这里插入图片描述
执行Build,打包出apk
在这里插入图片描述

10、真机测试

apk安装到手机上,手机可以正常访问摄像头,
请添加图片描述
我们在PC端运行客户端,连接手机的IP地址,
在这里插入图片描述
手机与电脑的视频聊天如下,画面清晰度切换,效果还是比较明显的,
请添加图片描述

四、工程源码

本文工程我以上次到CODE CHINA,感兴趣的同学可自行下载下来学习。
地址:https://codechina.csdn.net/linxinfa/UnityVideoChat
注:我使用的Unity版本为2021.1.7f1c1 个人版
在这里插入图片描述

五、完毕

好了,就写到这里吧,音频的同步我没有写,思路是使用UnityMicrophone进行声音采样,上传服务器进行转发,这里就留给各位去实现啦,

另外,如果本文的Mirror网络库你不熟悉,可能看代码会比较吃力,建议可以先看看我之前那篇文章中关于Mirror网络库的介绍:《【游戏开发实战】Unity使用Socket通信实现简单的多人聊天室(万字详解 | 网络 | TCP | 通信 | Mirror | Networking)》
在这里插入图片描述

天色已晚,我要去冲凉先了,拜拜~

我是林新发:https://blog.csdn.net/linxinfa
原创不易,若转载请注明出处,感谢大家~
喜欢我的可以点赞、关注、收藏,如果有什么技术上的疑问,欢迎留言或私信~

最后,与皮皮猫合个影吧~
请添加图片描述

猜你喜欢

转载自blog.csdn.net/linxinfa/article/details/119136203
今日推荐