数字孪生项目实战,WPF与Unity结合开发之路(一)

数字孪生项目实战,WPF与Unity结合开发之路(一)

作   者:水娃

  • 嗨大家好,我是一名骨灰级的WPF开发者,我叫水娃。

  • 这次主要是向大家讲解一个WPFUnity相结合来实现WPF3D的交互项目。此前一直做WPF开发,但是有时候要实现一些3D过程的时候,用WPF做就很麻烦。经过不断探索,作者总结了一套合理的WPFUnity通讯和嵌入方式,如果能用不同的技术相组合,用各自的技术做他擅长的方向,那既能达到产品需求又可以避免技术开发难点,是不是要比单用一门技术来实现要好很多呢?

  • 项目的起因是要做一个数字孪生项目,按照白皮书的解释,数字孪生分为几个阶段:

    • 1.虚实映射

    • 2.实时同步

    • 3.共生演进

    • 4.闭环优先。

  • 这里由于版权原因,我们只开源到第二部分实时同步阶段,属于集成前的测试程序,但是整体的集成方式和通讯过程已经全部实现了。项目主要实现对一个风机电厂中各种风机的监测和控制,由于风机的采集协议是用的Modbus,所以采集这部分选择WPF开发,SQLite存储。但是要用3D来实现风机的表现,比如风速、转向、掉线离线、不同风速对风机的影响,这部分如果用WPF来表现,那就很麻烦了,所以最终决定采用Unity来开发这部分。最后WPF里嵌入Unity来最终项目呈现。演示效果如下。

  • 我们分为三部分来开发:

    • 1.WPF部分

    • 2.Unity部分

    • 3.集成部分

(一)、WPF 测试界面如下:

dbeac93e50befe48103049fcf7a21f3a.png
  • 先简单介绍一下Modbus协议,Modbus是一个现场总线协议,应用在电子控制器上,可以实现控制器相互之间、PC到控制器的通讯。支持多种电气接口(RS232、RS422、RS485、RJ45)和多种传输介质(双绞线、网线)。主要有串口和网口方式,串口(电脑后面的串口孔,PC只有232串口的,所以需要买串口转换器,才能用485协议)主要是用RS485协议一主多从模式,传输格式有ModbusAsciiModbusRTU;

  • 网口(电脑后面插网线的口)主要是ModbusTCPModbusUDP,传输格式和串口ModbusRTU的相同。Modbus中数据存储类型为bit(bool),byte(8位),word(16位),dword(32位). 这几个类型的主要区别是存储的长度不同,类似C#里的intdouble。所谓的上位机一般都是用别人写好的库,连好硬件,然后根据地址表,从硬件中读出来对应的数据,然后再解析出来。 因为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);
                //监听该IP
                listenSocket.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;

   }
  • 其中的关键字段是epnamemsg_type,一个是风机名字,用来区分不同风机,一个是msg_type用来区分不同消息。其余字段都是用来控制风机的状态。

(三)、  Unity部分:

71086bfc91e590241c609c8a22572e26.png
  • 首先,我们需要建立地形,这个使用Unity内置的Terrain,就和WPF内置的控件一样,拖进去进去微调,然后拖进去风机模型,进行位置调整。调整完效果如下:

1973fb50d20c44dca370ba4510c48af3.png
  • 然后我们开始制作图表,这个图表的制作方式其实和WPF写界面是大同小异的。我们要实现的大屏界面如下:

2c2d4b9f99220058445b7a640c79b0ee.png
  • 首先实现最上面的部分,先把Unity设置为2D模式,然后增加一个Image控件和一个Text控件,Image控件选择背景图片,Text输入文字。标题栏就形成了,如下

356c4e47bdf1775e182144a178b66485.png
  • 下面的风机总数那几个圆形图表的开发方式也类似,如下

cca50b285598b18c57f8299927cfdf0b.png
  • 下面的几个图表也是类似的开发方式,是不是发现很简单?

  • 甚至比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()
        {
            //创建SOCKET
            socket = 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每个脚本都有一个StartUpdate函数,所以更新界面的操作都要在这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,传1
                      case "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函数里面进行逻辑判断,从而更改界面显示。

(四)、集成部分

  • wpfunity开发完成后,来到了最终的集成环节。

  • 这里我们不采用网上那种方式,作者自己经过几天研究,总结了一个比较好的集成方式。

  • 就是把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();
            }
        }
  • 好了,这次的分享到这里结束,之后我会把开发过程详细的写出来,帮助大家手把手 的从01搭建这个项目,最终这个项目也会集成到web里面。如果有不懂的可以随时加作者沟通,互相提高。二维码在下方。

e163415fae68c4749749c171db7effa6.gif d80fe6109a299ecc3129a1e4733dbe09.png 5bd4efed5bf68cf42733fb928cf96c13.jpeg
Logo

永洪科技,致力于打造全球领先的数据技术厂商,具备从数据应用方案咨询、BI、AIGC智能分析、数字孪生、数据资产、数据治理、数据实施的端到端大数据价值服务能力。

更多推荐