- 一、前言
- 二、思考问题与解决方案
- 1、思考问题
- 2、解决方案
- 2.1、Unity中如何开启摄像头并对图像进行采样
- 2.2、图像如何中转给其他客户端
- 2.3、如何实现清晰度切换
- 2.4、客户端如何对图像进行解码并显示
- 三、实际操作
- 0、思维导图
- 1、界面设计与制作
- 2、UI素材获取
- 3、创建Unity工程
- 4、制作UI界面
- 5、下载Mirror网络插件
- 6、写C#代码
- 6.1、网络管理器:VideoChatNetwork.cs
- 6.2、摄像头画面:Player.cs
- 6.3、业务逻辑:MainLogic.cs
- 6.4、界面交互:MainPanel.cs
- 7、挂脚本
- 7.1、VideoChatNetwork脚本
- 7.2、Player脚本
- 7.3、MainPanel脚本
- 8、Editor环境下测试
- 9、发布应用
- 9.1、发布Windows平台exe
- 9.2、发布Android平台apk
- 10、真机测试
- 四、工程源码
- 五、完毕
嗨,大家好,我是新发。
事情是这样的,我前几天写了一篇《【游戏开发实战】Unity使用Socket通信实现简单的多人聊天室(万字详解 | 网络 | TCP | 通信 | Mirror | Networking)》
有同学留言问我多人在线视频聊天切换清晰度怎么做,
嗯,作为一位热心的技术博主,我一般都是能帮则帮。
嘛,今天就来写个多人视频聊天的功能吧(并且可以切换清晰度)。
多人视频聊天大家应该都不陌生,像腾讯视频会议那样,多个人的视频画面同时显示在界面中。
我的实现思路是每个客户端对本地摄像头画面进行采样,得到帧图像,然后对图像进行适当的压缩,转为字节流上传给服务端,接着服务端根据每个客户端设置的清晰度对帧图像进行压缩,然后转发帧图像给其他客户端,其他客户端接收到帧图像字节流后进行解码,最后显示到界面中。
画成图是这样子:
要实现上面的功能,我们需要先思考并解决以下几个必要问题:
1 Unity中如何开启摄像头并对图像进行采样?
2 图像如何中转给其他客户端?
3 如何实现清晰度切换?
4 客户端如何对图像进行解码并显示?
Unity提供了WebCamTexture这个类,通过它我们可以很方便的访问摄像头图像。
具体用法我下文会讲。
正常情况我们需要搭建一个中转的服务端,要实现数据的序列化、网络通信、数据的反序列化等。这里我想使用Mirror网络库来实现。在之前那篇文章中我也有介绍过Mirror:《【游戏开发实战】Unity使用Socket通信实现简单的多人聊天室(万字详解 | 网络 | TCP | 通信 | Mirror | Networking)》
客户端把清晰度设置告诉服务端,服务端根据清晰度对图像进行压缩,把压缩后的图像下发给客户端。
由于我们是使用Mirror,服务端也是Unity客户端,所以我们可以直接使用Texture2D的EncodeToJPG接口对图像进行压缩,第二个参数就是压缩率,值从1~100(默认是75),
public static byte[] EncodeToJPG(this Texture2D tex, int quality);2.4、客户端如何对图像进行解码并显示
通过网络传输过来的图像是字节流,我们需要把它反序列化为Unity可现实的图像Texture2D,我们直接使用Texture2D的LoadImage接口,
public static bool LoadImage(this Texture2D tex, byte[] data);三、实际操作
下面,撸起袖子开始动手实际操作吧~
0、思维导图养成好习惯,动手前先画思维图,如下:
使用axure快速原型设计工具先简单设计一下界面,
登录界面,Host就是作为房主,Client就是作为路人,
视频聊天界面,排列显示多个视频画面,可切换视频清晰度,可随时退出房间,
简单的UI素材资源我是在阿里巴巴矢量图库上找,地址:https://www.iconfont.cn/
比如搜索按钮,
找一个形状合适的,可以进行调色,我一般是调成白色,
因为Unity中可以设置Color,这样我们只需要一个白色按钮就可以在Unity中创建不同颜色的按钮了。
弄点基础的美术资源,
注:那个头像是我自己用PhotoShop画的哦,我之前用PhotoShop画过一幅原创连环画,如下:
我使用的Unity版本为2021.1.7f1c1 个人版,我们要做的是一个多人视频聊天的功能,不需要使用到3D相关的内容,所以我们创建工程时使用2D模板,工程名就叫UnityVideoChat吧~
创建成功,
根据我们的原型设计,使用UGUI制作界面:MainPanel.prefab,
如下,
其中用于渲染视频图像的UI独立做成一个预设:VideoImage.prefab,方便进行克隆(每连接一客户端就克隆一个VideoImage)
如下,使用RawImage组件来显示图像,
Mirror的Unity Asset Store地址:
https://assetstore.unity.com/packages/tools/network/mirror-129321
将Mirror插件添加到自己的账号中,然后回到Unity,在Package Manager中就可以下载了,
下载下来导入Unity中,
先画个图,方便大家直观地知道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(OnCreatePlayer);
// ...
}
void OnCreatePlayer(NetworkConnection connection, CreatePlayerMessage createPlayerMessage)
{
// 实例化Player
GameObject playergo = Instantiate(playerPrefab);
playergo.GetComponent().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.exe对dll进行反编译)。
[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().accountName = createPlayerMessage.name;
变成了:
playergo.GetComponent().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.csMainLogic思维导图:
注: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;
}
}
}
定义成员变量:
////// 用户名 /// public string accountName; ////// 清晰度,0:高清,1:标清,2:普通 /// public int definition;
初始化,设置成员变量和回调函数:
private Action backToLoginCb; ////// 初始化 /// /// 网络管理器 /// 回调登录界面的回调函数 public void Init(VideoChatNetwork network, Action backToLoginCb) { this.network = network; this.backToLoginCb = backToLoginCb; } public void OnClientDisconnect() { if (null != backToLoginCb) backToLoginCb(); }
启动服务端,IP地址默认是localhost:
////// 启动服务端 /// /// IP地址 /// 用户名 /// 成功回调函数 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:
////// 启动客户端 /// /// IP地址 /// 用户名 /// 回调函数 public void StartClient(string ip, string account, Action cb) { this.accountName = account; network.networkAddress = ip; network.DoStartClient(cb); }
关闭网络:
////// 关闭网络 /// public void Close() { network.StopHost(); network.StopClient(); }
切换清晰度:
////// 切换清晰度 /// public void SwitchDefinition(int v) { definition = v; } ////// 根据图像清晰度进行图像压缩 /// 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:
////// 字节流转Texture2D /// /// 图像字节流 ///public Texture2D BytesToTexture2D(byte[] textureBytes) { Texture2D tex2D = new Texture2D(30, 30); tex2D.LoadImage(textureBytes); return tex2D; }
摄像头图像转字节流:
///6.4、界面交互:MainPanel.cs/// 摄像头图像转字节流 /// /// 摄像头帧画面 /// 压缩了,1~100,默认75 ///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; }
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(), () =>
{
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组件上可以设置端口号,
给VideoImage.prefab预设根节点挂上````Player脚本(它会自动挂上NetworkIdentity脚本),赋值Player脚本的UI```成员,
给MainPanel.prefab预设挂上MainPanel脚本,并赋值UI成员,
在Editor环境下运行,效果如下:
转为PC平台,添加场景,
设置窗口尺寸为1280*720,
执行Build,打出exe,
转为Android平台,添加场景,
设置包名为com.linxinfa.videochat,
执行Build,打包出apk,
把apk安装到手机上,手机可以正常访问摄像头,
我们在PC端运行客户端,连接手机的IP地址,
手机与电脑的视频聊天如下,画面清晰度切换,效果还是比较明显的,
本文工程我以上次到CODE CHINA,感兴趣的同学可自行下载下来学习。
地址:https://codechina.csdn.net/linxinfa/UnityVideoChat
注:我使用的Unity版本为2021.1.7f1c1 个人版
好了,就写到这里吧,音频的同步我没有写,思路是使用Unity的Microphone进行声音采样,上传服务器进行转发,这里就留给各位去实现啦,
另外,如果本文的Mirror网络库你不熟悉,可能看代码会比较吃力,建议可以先看看我之前那篇文章中关于Mirror网络库的介绍:《【游戏开发实战】Unity使用Socket通信实现简单的多人聊天室(万字详解 | 网络 | TCP | 通信 | Mirror | Networking)》
天色已晚,我要去冲凉先了,拜拜~
我是林新发:https://blog.csdn.net/linxinfa
原创不易,若转载请注明出处,感谢大家~
喜欢我的可以点赞、关注、收藏,如果有什么技术上的疑问,欢迎留言或私信~
最后,与皮皮猫合个影吧~



