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

Posted 林新发

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了游戏开发实战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),

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 以上是关于游戏开发实战Unity从零开发多人视频聊天功能,无聊了就和自己视频聊天(附源码 | Mirror | 多人视频 | 详细教程)的主要内容,如果未能解决你的问题,请参考以下文章

游戏开发实战Unity使用Socket通信实现简单的多人聊天室(万字详解 | 网络 | TCP | 通信 | Mirror | Networking)

游戏开发实战Unity使用Socket通信实现简单的多人聊天室(万字详解 | 网络 | TCP | 通信 | Mirror | Networking)

游戏开发实战Unity从零做一个任务系统,人生如梦,毕业大学生走上人生巅峰(含源码工程 | 链式任务 | 主线支线)

游戏开发实战Unity从零做一个任务系统,人生如梦,毕业大学生走上人生巅峰(含源码工程 | 链式任务 | 主线支线)

unity3D游戏开发实战原创视频讲座系列13之帽子戏法游戏开发(预告)

unity3D游戏开发实战原创视频讲座系列7之消消乐游戏开发