数字孪生项目实战,WPF与Unity结合开发之路(一)
数字孪生项目实战,WPF与Unity结合开发之路(一)
作 者:水娃
嗨大家好,我是一名骨灰级的
WPF
开发者,我叫水娃。这次主要是向大家讲解一个
WPF
与Unity
相结合来实现WPF
和3D
的交互项目。此前一直做WPF
开发,但是有时候要实现一些3D过程的时候,用WPF做就很麻烦。经过不断探索,作者总结了一套合理的WPF
与Unity
通讯和嵌入方式,如果能用不同的技术相组合,用各自的技术做他擅长的方向,那既能达到产品需求又可以避免技术开发难点,是不是要比单用一门技术来实现要好很多呢?项目的起因是要做一个数字孪生项目,按照白皮书的解释,数字孪生分为几个阶段:
1.虚实映射
2.实时同步
3.共生演进
4.闭环优先。
这里由于版权原因,我们只开源到第二部分实时同步阶段,属于集成前的测试程序,但是整体的集成方式和通讯过程已经全部实现了。项目主要实现对一个风机电厂中各种风机的监测和控制,由于风机的采集协议是用的
Modbus
,所以采集这部分选择WPF
开发,SQLite
存储。但是要用3D
来实现风机的表现,比如风速、转向、掉线离线、不同风速
对风机的影响,这部分如果用WPF
来表现,那就很麻烦了,所以最终决定采用Unity
来开发这部分。最后WPF
里嵌入Unity
来最终项目呈现。演示效果如下。我们分为三部分来开发:
1.WPF部分
2.Unity部分
3.集成部分
(一)、WPF 测试界面如下:
先简单介绍一下
Modbus
协议,Modbus
是一个现场总线协议,应用在电子控制器上,可以实现控制器相互之间、PC到控制器
的通讯。支持多种电气接口(RS232、RS422、RS485、RJ45)和多种传输介质(双绞线、网线)
。主要有串口和网口方式,串口(电脑后面的串口孔,PC
只有232串口
的,所以需要买串口转换器,才能用485协议
)主要是用RS485协议
,一主多从模式
,传输格式有ModbusAscii
和ModbusRTU
;网口(电脑后面插网线的口)主要是
ModbusTCP
和ModbusUDP
,传输格式和串口ModbusRTU
的相同。Modbus
中数据存储类型为bit(bool)
,byte(8位)
,word(16位)
,dword(32位)
. 这几个类型的主要区别是存储的长度不同,类似C#
里的int
和double
。所谓的上位机一般都是用别人写好的库,连好硬件,然后根据地址表,从硬件中读出来对应的数据,然后再解析出来。 因为C#
的最小单位是byte
,所以我们读取完之后,一切都是byte[]
, 一定要写好解析过程,不然就会出错。Modbus
这部分解决了,数据有了,下一步就是要从WPF
发送给Unity
,作者选择了Socket
协议,别的一些网络协议也可以,但是Socket
比较成熟,作者用的比较熟悉。最终封装的类库代码如下
public class socketServer{public class StateObject{// Client socket.public Socket workSocket = null;// Size of receive buffer.public const int BufferSize = 1024;// Receive buffer.public byte[] buffer = new byte[BufferSize];}public class ConnectionServer{public static ConnectionServer Instance => _instance;private static readonly ConnectionServer _instance = new ConnectionServer();/// <summary>/// 监听线程/// </summary>public Socket listenSocket;/// <summary>/// tcp客户端对象/// </summary>public Socket clientSocket = null;/// <summary>/// 异步发送数据/// </summary>/// <returns></returns>public int Send(byte[] byteMessage, int size){int offset = 0;try{return SendBytes(byteMessage, size, ref offset);}catch (Exception ex){Console.WriteLine($"发送出现异常{ex.Message.ToString()}");return 0;}}private int SendBytes(byte[] byteMessage, int size, ref int offset){if (clientSocket != null){while (offset < size){int n = clientSocket.Send(byteMessage, offset, size - offset, SocketFlags.None);if (n > 0){offset += n;}else{Console.WriteLine("发送数据失败");break;}}return offset;}return 0;}public void Start(string ipServer, int portServer){IPEndPoint ipEnd = new IPEndPoint(IPAddress.Parse(ipServer), portServer);//创建监听listenSocket = new Socket(ipEnd.AddressFamily, SocketType.Stream, ProtocolType.Tcp);//监听该IPlistenSocket.Bind(ipEnd);//设置监听队列中最多可容纳的等待接受的传入连接数listenSocket.Listen(100);Console.WriteLine($"开始监听:{ipServer}:{portServer}");//开始接受客户端连接while (true){clientSocket = listenSocket.Accept();var ip = ((IPEndPoint)clientSocket.RemoteEndPoint).Address;var port = ((IPEndPoint)clientSocket.RemoteEndPoint).Port;var appkey = $"{ip}^{port}";if (clientSocket.Connected){Console.WriteLine($"{appkey}连接到了服务端");try{// 开始异步接受数据SetupReceiveCallback();}catch (Exception ex){Console.WriteLine("Socket异步方式接收数据发生异常:{0}", ex.StackTrace);}}else{Console.WriteLine("连接建立失败");}}}/// <summary>/// 开始用Socket异步方式接收数据。/// </summary>private void SetupReceiveCallback(){if (clientSocket != null){try{StateObject state = new StateObject();state.workSocket = clientSocket;clientSocket.BeginReceive(state.buffer, 0, StateObject.BufferSize, SocketFlags.None,new AsyncCallback(OnReceive), state);}catch (Exception ex){Console.WriteLine("Socket异步方式接收数据发生异常:{0}", ex.StackTrace);}}else{Console.WriteLine("异步接收回报消息socket为null");}}/// <summary>/// 异步接收回调/// </summary>/// <param name="ar"></param>private void OnReceive(IAsyncResult ar){try{if (clientSocket != null){StateObject state = (StateObject)ar.AsyncState;Socket client = state.workSocket;// Read data from the remote device.int bytesRead = client.EndReceive(ar);if (bytesRead > 0){byte[] result = new byte[bytesRead];Buffer.BlockCopy(state.buffer, 0, result, 0, bytesRead);var msg = Encoding.UTF8.GetString(result);Console.WriteLine("收到消息:" + msg);MsgCenter.Receive(msg);// Send(result, result.Length);SetupReceiveCallback();}else{Console.WriteLine("异步接受数据bytesRead为0");}}}catch (Exception ex){Console.WriteLine($"异步接受数据异常{ex.Message}");}}}}
这个类没有处理粘包情况,都是直接发直接收和解析,如果不是高频通讯,比如毫秒级的通讯,其实粘包情况很少发生。
一般处理粘包都是一个消息分为
消息头+消息体+消息尾巴
,或者简单一点直接消息头+消息体的形式。消息头里面一般会有序号和消息体的长度,方便接收端进行处理。由于项目通讯频率不高,每台风机是1s
通讯一次,也就是1s WPF
会把一个风机数据发送给Unity
,一个程序中最多有10
台风机,所以1s
最多发送10
次。因为都是本机通讯,经过大量测试,没有出现粘包的情况,所以测试通讯类只封装了发送和接收,实际使用起码要封装断线重连,心跳检测才能真正使用。
(二)、 然后定义通讯格式,代码如下:
public class MessageModel{/// <summary>/// id/// </summary>public string msid;/// <summary>///风机名字/// </summary>public string epname;/// <summary>///消息类型/// </summary>public string msg_type;/// <summary>/// 状态字服务器/// </summary>public string severztz;/// <summary>/// 控制字服务器/// </summary>public string severkzz;/// <summary>/// 偏航修正量,绝对值/// </summary>public string severphxzl;/// <summary>/// 风速修正量,相对值/// </summary>public string severfxxzl;/// <summary>/// 计数器/// </summary>public string severjzq;/// <summary>/// reserved/// </summary>public string severreserved;/// <summary>/// 状态字/// </summary>public string ztz;/// <summary>/// 计数器/// </summary>public string jsq;/// <summary>/// 风速/// </summary>public string fs;/// <summary>/// 风向/// </summary>public string fx;/// <summary>/// 机舱方位角/// </summary>public string jcfwj;/// <summary>/// 雷达风速/// </summary>public string ldfs;/// <summary>/// 雷达风向/// </summary>public string ldfx;/// <summary>/// 雷达状态字1/// </summary>public string ldztzone;/// <summary>/// 雷达状态字2/// </summary>public string ldztztwo;/// <summary>///雷达判断数据是否有效/// </summary>public string ldsfyx;/// <summary>///雷达扫描层/// </summary>public string dyldsmc;/// <summary>///轴向风速/// </summary>public string zxfs;/// <summary>///水平风速/// </summary>public string spfs;/// <summary>///垂直风速/// </summary>public string czfs;/// <summary>///光束1/// </summary>public string vlos1;/// <summary>///光束2/// </summary>public string vlos2;/// <summary>///光束3/// </summary>public string vlos3;/// <summary>///光束4/// </summary>public string vlos4;/// <summary>///光束5/// </summary>public string vlos5;/// <summary>///光束6/// </summary>public string vlos6;/// <summary>///光束7/// </summary>public string vlos7;/// <summary>///光束8/// </summary>public string vlos8;/// <summary>///光束9/// </summary>public string vlos9;/// <summary>///光束10/// </summary>public string vlos10;/// <summary>///光束11/// </summary>public string vlos11;/// <summary>///光束12/// </summary>public string vlos12;/// <summary>///光束13/// </summary>public string vlos13;/// <summary>///光束14/// </summary>public string vlos14;/// <summary>///光束15/// </summary>public string vlos15;/// <summary>///光束16/// </summary>public string vlos16;/// <summary>///光束测量有效性/// </summary>public string gsclyxx;/// <summary>///reserved1/// </summary>public string reserved1;/// <summary>///reserved2/// </summary>public string reserved2;/// <summary>///reserved3/// </summary>public string reserved3;}
其中的关键字段是
epname
和msg_type
,一个是风机名字,用来区分不同风机,一个是msg_type
用来区分不同消息。其余字段都是用来控制风机的状态。
(三)、 Unity
部分:
首先,我们需要建立地形,这个使用
Unity
内置的Terrain
,就和WPF
内置的控件一样,拖进去进去微调,然后拖进去风机模型,进行位置调整。调整完效果如下:
然后我们开始制作图表,这个图表的制作方式其实和
WPF
写界面是大同小异的。我们要实现的大屏界面如下:
首先实现最上面的部分,先把
Unity
设置为2D
模式,然后增加一个Image
控件和一个Text
控件,Image
控件选择背景图片,Text
输入文字。标题栏就形成了,如下
下面的风机总数那几个圆形图表的开发方式也类似,如下
下面的几个图表也是类似的开发方式,是不是发现很简单?
甚至比
WPF
的界面开发也要简单,有时候高手和我们的差距,就是他们懂很多我们不懂的基础知识,因为不懂,被高手一顿组合拳下来,老戳中我们的盲点,就觉得高手比较厉害,其实坚持学一学,我们也可以成为高手,虽然我现在也是个菜鸟。界面开发方式结束了,再来看看后台代码,同样的我们也需要一个
Socket
接收类,如下:
public class ConnectionClient{public static ConnectionClient Instance => _instance;private static readonly ConnectionClient _instance = new ConnectionClient();private string ip { get; set; }private int port { get; set; }/// <summary>/// 当前状态/// </summary>public ConnState CurrState { get; set; }/// <summary>/// tcp客户端对象/// </summary>private Socket socket = null;/// <summary>/// 上一个队列数据中剩余字节长度/// </summary>private byte[] lastBytes;public bool InitConnection(){//创建SOCKETsocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);CurrState = ConnState.Connecting;socket.NoDelay = true;socket.ReceiveTimeout = 10000;socket.SendTimeout = 5000;return true;}public bool ConnectServer(string ipServer, int portServer){if (socket.Connected){CurrState = ConnState.Connected;Console.WriteLine("已经有了连接");return true;}try{Console.WriteLine("开始建立连接");this.ip = ipServer;this.port = portServer;IPEndPoint ipEnd = new IPEndPoint(IPAddress.Parse(ip), port);socket.Connect(ipEnd);if (socket.Connected){//接受数据SetupReceiveCallback();CurrState = ConnState.Connected;}else{CurrState = ConnState.Disconnected;Console.WriteLine("连接不上");}}catch (Exception ex){CurrState = ConnState.Disconnected;Console.WriteLine("连接socket异常" + ex.Message.ToString());return false;}if (CurrState == ConnState.Connected){return true;}return false;}public void Reconnection(){while (true){if (CurrState == ConnState.Disconnected){InitConnection();ConnectServer(ip, port);}else { }Thread.Sleep(3000);}}/// <summary>/// 开始用Socket异步方式接收数据。/// </summary>protected void SetupReceiveCallback(){if (socket != null){try{StateObject state = new StateObject();state.workSocket = socket;socket.BeginReceive(state.buffer, 0, StateObject.BufferSize, SocketFlags.None,new AsyncCallback(OnReceive), state);}catch (Exception ex){Console.WriteLine("Socket异步方式接收数据发生异常:{0}", ex.Message.ToString());}}else{Console.WriteLine("异步接收回报消息socket为null");}}/// <summary>/// 异步接收回调/// </summary>/// <param name="ar"></param>private void OnReceive(IAsyncResult ar){CurrState = ConnState.Connected;try{StateObject state = (StateObject)ar.AsyncState;Socket client = state.workSocket;int bytesRead = socket.EndReceive(ar);if (bytesRead > 0){byte[] result = new byte[bytesRead];Buffer.BlockCopy(state.buffer, 0, result, 0, bytesRead);var msg = Encoding.UTF8.GetString(result);var model = Newtonsoft.Json.JsonConvert.DeserializeObject<MessageModel>(msg);// Debug.Log(msg);ReceiveAction(model);SetupReceiveCallback();}else{CurrState = ConnState.Disconnected;}}catch (Exception ex){Console.WriteLine($"发生异常{ex.Message.ToString()}");}}/// <summary>/// 异步发送数据/// </summary>/// <returns></returns>public int Send(byte[] byteMessage, int size){int offset = 0;try{return SendBytes(byteMessage, size, ref offset);}catch (Exception ex){Console.WriteLine($"发送出现异常{ex.Message.ToString()}");return 0;}}private int SendBytes(byte[] byteMessage, int size, ref int offset){if (socket != null){while (offset < size){int n = socket.Send(byteMessage, offset, size - offset, SocketFlags.None);if (n > 0){offset += n;}else{Console.WriteLine("发送数据失败");break;}}return offset;}return 0;}}void Start(){ConnectionClient.Instance.InitConnection();ConnectionClient.Instance.ConnectServer(GlobalInit.basicInfoDict["ServerIP"], int.Parse(GlobalInit.basicInfoDict["Port"]));ReceiveAction = Receive;
}void Receive(MessageModel msg){lock (lockObject){message.Add(msg);}}
与
WPF
不同之处在于,Unity
每个脚本都有一个Start
和Update
函数,所以更新界面的操作都要在这2个函数内执行。Start
是初始化时候执行的,Update
是更新每一帧画面时候执行的(Unity
的渲染原理是根据计算机不同,1s
内固定更新多少帧图像,然后图像连起来就形成了实时画面)。所以最终我们改变界面的代码要写到
Update
内,他无法像WPF
一样可以自由切换UI线程。因此我们Socket
收到的数据全部扔到了List<MessageModel> message
里面,然后在Update
里面判断Message
的信息,来对界面进行改变。如下:
void Update(){lock (lockObject){if (message.Count != 0){for (int i = 0; i < message.Count; i++){var model = message[i];//Debug.Log(model.epname);switch (model.msg_type){//sqlite 10,传0 sql server 11,传1case "10":GameManager.Instance.InitSql(0);message.Remove(model);break;case "11":GameManager.Instance.InitSql(1);message.Remove(model);break;//100为实时数据 case "100":GameManager.Instance.WeiLiu(model);RightPanel.Instance.SetInfo(model);RightPanel.Instance.SetFengJiState(model);message.Remove(model);break;//case "200":// GameManager.Instance.SetModelEffect(model.vlos1, model.vlos2);// break;}}message.Clear();}}}
最终,
Unity
的开发过程总结一下就是:1.导入风机模型,记录一个初始位置,然后隐藏风机,点击新建时候克隆这个风机,输入属性后存储到
sqlite
数据库里面。2.写好
socket
接受类,收到wpf
传来的消息,在update
函数里面进行逻辑判断,从而更改界面显示。
(四)、集成部分
wpf
和unity
开发完成后,来到了最终的集成环节。这里我们不采用网上那种方式,作者自己经过几天研究,总结了一个比较好的集成方式。
就是把
unity
固定的放到wpf
界面的一个区域内,在移动和放大缩小wpf
界面的时候,不断的对unity
程序进行移动和放大缩小,这样整体保持了一致。主要是用几个
windows
函数来操作:
[DllImport("user32.dll", CharSet = CharSet.Auto)]static extern int MoveWindow(IntPtr hWnd, int x, int y, int nWidth, int nHeight, bool BRePaint);[DllImport("user32.dll", CharSet = CharSet.Auto, ExactSpelling = true)]public static extern int ShowWindow(IntPtr hwnd, int nCmdShow);[DllImport("gdi32.dll")]private static extern int GetDeviceCaps(IntPtr hDc, int nIndex);
这里要注意,
wpf
界面要选择window
,不能选择page
,因为page
页面没有句柄,无法把Unity
程序设置为wpf
界面的子元素。代码如下:
public MainWindow(){//静态指定Current = this;//窗口关闭方式,主窗口一旦关闭,就关闭Application.Current.ShutdownMode = ShutdownMode.OnMainWindowClose;//初始化窗口大小,到屏幕的80%this.Height = SystemParameters.PrimaryScreenHeight * 0.8d;this.Width = SystemParameters.PrimaryScreenWidth * 0.8d;//读取配置文件option = new ConfigurationBuilder().SetBasePath(Directory.GetCurrentDirectory()).AddJsonFile("AppSetting.json").Build().GetSection("config").Get<Option>();option.UnityDir = Environment.CurrentDirectory + "\\unity\\demo.exe";InitializeComponent();}private void Window_Loaded(object sender, RoutedEventArgs e){//开始socket监听Task.Run(() => ConnectionServer.Instance.Start(option.Ip, option.Port));//开启渲染窗口,并设置父级RenderWindow.Current.OpenRenderWindow();//实际应该延时后建立socket后立马发送选择数据库的信号// Thread.Sleep(2000);//Button_Click_3(new object(), new RoutedEventArgs());}//初始化,调整渲染窗口public void Init(){Window_SizeChanged(null, null);}public void OpenRenderWindow(){//渲染程序路径string RenderExePath = MainWindow.option.UnityDir;//如果成功找到了渲染程序if (!string.IsNullOrEmpty(RenderExePath) &&System.IO.File.Exists(RenderExePath)){UnityEngine = Process.Start(RenderExePath);Thread.Sleep(3000);SetRenderWindow();}//没找到渲染程序,就关闭else{MessageBox.Show("未找到渲染程序");//System.Windows.Application.Current.Shutdown();}}
好了,这次的分享到这里结束,之后我会把开发过程详细的写出来,帮助大家手把手 的从
0
到1
搭建这个项目,最终这个项目也会集成到web
里面。如果有不懂的可以随时加作者沟通,互相提高。二维码在下方。