数字孪生项目实战,WPF与Unity结合开发之路(一)
数字孪生项目实战,WPF与Unity结合开发之路(一)数字孪生项目实战,WPF与Unity结合开发之路(一)作者:水娃嗨大家好,我是一名骨灰级的WPF开发者,我叫水娃。这次主要是向大家讲解一个WPF与Unity相结合来实现WPF和3D的交互项目。此前一直做WPF开发,但是有时候要实现一些3D过程的时候,用WPF做就很麻烦。经过不断探索,作者总结了一套合理的WPF与Unity通讯和嵌入方式...
数字孪生项目实战,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);
//监听该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;
}
其中的关键字段是
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()
{
//创建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
每个脚本都有一个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,传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
函数里面进行逻辑判断,从而更改界面显示。
(四)、集成部分
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
里面。如果有不懂的可以随时加作者沟通,互相提高。二维码在下方。



更多推荐
所有评论(0)