数字孪生项目实战,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里面。如果有不懂的可以随时加作者沟通,互相提高。二维码在下方。


