最近由于项目需要,对Unity3D应用嵌入WPF应用进行了研究,并通过Socket实现了两者的通信。由于Unity3D在5.4.x版本后不再支持WebPlayer,所以并未使用UnityWebPlayer,另外考虑到我们原有的业务系统都是基于WPF的,全部改到Unity3D里面工作量会很大,所以采用了将Unity3D生成的exe可执行程序直接嵌入到WPF中的做法。
我们的设想是WPF程序作为主程序负责业务逻辑处理和页面展示,Unity3D程序作为子程序负责模型展示和交互,二者通过Socket建立连接、相互传递和接收消息以实现操作联动。例如在主程序点击页面按钮触发模型内特定对象高亮显示或点击模型内任意对象联动主程序显示关联数据。

环境

版本

操作系统

Windows 10 prefessional

编译器

Visual Studio 2015 update3


创建WPF应用并嵌入Unity3D应用

从本质上来讲,这是一个Win32应用程序嵌入到WPF应用程序的问题,由于两者窗口绘制原理的差异,就必需依靠Win32API,也就是大家常常会提到的user32.dll。C#中使用Win32Api与C++略有不同,需要使用DllInput,对引用user32.dll进行了简单封装,代码如下:

public class Win32Helper
    {
        [DllImport("user32.dll")]
        static extern IntPtr SetActiveWindow(IntPtr hWnd);

        [DllImport("user32.dll", CharSet = CharSet.Auto, ExactSpelling = true)]
        public static extern IntPtr GetForegroundWindow();

        [DllImport("user32.dll")]
        public static extern bool SetForegroundWindow(IntPtr hWnd);

        [DllImport("user32.dll", EntryPoint = "GetWindowThreadProcessId", SetLastError = true,
           CharSet = CharSet.Unicode, ExactSpelling = true,
           CallingConvention = CallingConvention.StdCall)]
        public static extern long GetWindowThreadProcessId(long hWnd, long lpdwProcessId);

        [DllImport("user32.dll", SetLastError = true)]
        public static extern IntPtr FindWindow(string lpClassName, string lpWindowName);

        [DllImport("user32.dll", SetLastError = true)]
        public static extern int SetParent(IntPtr hWndChild, IntPtr hWndNewParent);

        [DllImport("user32.dll")]
        public static extern uint GetWindowLong(IntPtr hwnd, int nIndex);

        [DllImport("user32.dll", EntryPoint = "SetWindowLong", CharSet = CharSet.Auto)]
        public static extern uint SetWindowLong(IntPtr hWnd, int nIndex, uint dwNewLong);

        [DllImport("user32.dll", SetLastError = true)]
        public static extern long SetWindowPos(IntPtr hwnd, long hWndInsertAfter, long x, long y, long cx, long cy, long wFlags);

        [DllImport("user32.dll", SetLastError = true)]
        public static extern bool MoveWindow(IntPtr hwnd, int x, int y, int cx, int cy, bool repaint);

        [DllImport("user32.dll", EntryPoint = "ostMessageA", SetLastError = true)]
        public static extern bool PostMessage(IntPtr hwnd, uint Msg, uint wParam, uint lParam);

        [DllImport("user32.dll", SetLastError = true)]
        public static extern IntPtr GetParent(IntPtr hwnd);

        [DllImport("user32.dll", EntryPoint = "ShowWindow", SetLastError = true)]
        public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);

        [DllImport("User32.dll", EntryPoint = "SendMessage")]
        public static extern int SendMessage(IntPtr hWnd, int Msg, IntPtr wParam, IntPtr lParam);

        [DllImport("user32.dll", SetLastError = true)]
        public static extern void SwitchToThisWindow(IntPtr hWnd, bool fAltTab);

        public const int SWP_NOOWNERZORDER = 0x200;
        public const int SWP_NOREDRAW = 0x8;
        public const int SWP_NOZORDER = 0x4;
        public const int SWP_SHOWWINDOW = 0x0040;
        public const int WS_EX_MDICHILD = 0x40;
        public const int SWP_FRAMECHANGED = 0x20;
        public const int SWP_NOACTIVATE = 0x10;
        public const int SWP_ASYNCWINDOWPOS = 0x4000;
        public const int SWP_NOMOVE = 0x2;
        public const int SWP_NOSIZE = 0x1;
        public const int GWL_STYLE = (-16);
        public const int WS_VISIBLE = 0x10000000;
        public const int WS_MAXIMIZE = 0x01000000;
        public const int WS_BORDER = 0x00800000;
        public const int WM_CLOSE = 0x10;
        public const int WS_CHILD = 0x40000000;
        public const int WS_POPUP = -2147483648;
        public const int WS_CLIPSIBLINGS = 0x04000000;
        public const int SW_HIDE = 0; //{隐藏, 并且任务栏也没有最小化图标}
        public const int SW_SHOWNORMAL = 1; //{用最近的大小和位置显示, 激活}
        public const int SW_NORMAL = 1; //{同 SW_SHOWNORMAL}
        public const int SW_SHOWMINIMIZED = 2; //{最小化, 激活}
        public const int SW_SHOWMAXIMIZED = 3; //{最大化, 激活}
        public const int SW_MAXIMIZE = 3; //{同 SW_SHOWMAXIMIZED}
        public const int SW_SHOWNOACTIVATE = 4; //{用最近的大小和位置显示, 不激活}
        public const int SW_SHOW = 5; //{同 SW_SHOWNORMAL}
        public const int SW_MINIMIZE = 6; //{最小化, 不激活}
        public const int SW_SHOWMINNOACTIVE = 7; //{同 SW_MINIMIZE}
        public const int SW_SHOWNA = 8; //{同 SW_SHOWNOACTIVATE}
        public const int SW_RESTORE = 9; //{同 SW_SHOWNORMAL}
        public const int SW_SHOWDEFAULT = 10; //{同 SW_SHOWNORMAL}
        public const int SW_MAX = 10; //{同 SW_SHOWNORMAL}
        public const int WM_SETTEXT = 0x000C;
        public const int WM_ACTIVATE = 0x0006;
        public static readonly IntPtr WA_ACTIVE = new IntPtr(1);
        public static readonly IntPtr WA_INACTIVE = new IntPtr(0);

    }

有了Win32Api下步就可以着手将Unity3D应用嵌入WPF应用。大致思路就是创建一个进程用来启动Unity3D应用,同时在WPF应用中使用WindowsFormsHost划定一块区域用来放置Unity3D,并将其作为子窗口。XAML页面布局如下:

<Grid>
        <WindowsFormsHost>
            <form:Panel x:Name="unityHost"></form:Panel>
        </WindowsFormsHost>
    </Grid>

后台代码如下:

private Process process;
        public IntPtr childHandle;

        private void UnityInit()
        {
            string path = Environment.CurrentDirectory + @"\Unity\HelloUnity.exe";
            IntPtr hostHandle = unityHost.Handle;
            process = new Process();
            process.StartInfo.FileName = path;
            //process.StartInfo.Arguments = "-parentHWND " + panel1.Handle.ToInt32() + " " + Environment.CommandLine;
            process.StartInfo.UseShellExecute = true;
            process.StartInfo.CreateNoWindow = true;
            process.Start();
            //process.WaitForInputIdle();

            childHandle = process.MainWindowHandle;
            while (childHandle == IntPtr.Zero)
            {
                childHandle = process.MainWindowHandle;
            }
            uint oldStyle = Win32Helper.GetWindowLong(childHandle, Win32Helper.GWL_STYLE);
            //Win32Helper.SetWindowLong(childHandle, Win32Helper.GWL_STYLE, (oldStyle | WS_CHILD) & ~WS_BORDER);
            Win32Helper.SetWindowLong(childHandle, Win32Helper.GWL_STYLE, oldStyle & ~WS_BORDER);//去除边框
            Win32Helper.SetParent(childHandle, hostHandle);//设为子窗体
            Win32Helper.MoveWindow(childHandle, -2, -2, unityHost.Width+4, unityHost.Height+4, true);//移动窗口位置
        }

Unity3D应用与WPF应用通信

上一阶段将Unity3D应用已经嵌入到了WPF应用,但二者实际上仍没有任何联系,相互独立,并不知道对方在做什么,也就无法实现联动。为了实现联动,他们相互之间就需要传输数据,这就回归到了Windows窗口传递消息的问题上。窗口间传递消息有多种方法,比如使用Win32Api传递消息、Socket等,由于对Win32Api掌握的还不是很熟练,于是参考了网上使用Socket传递消息的示例。
Socket通信包含两部分,其中WPF应用作为服务端,Unity3D作为客户端,两者先后启动建立连接就可以相互发送和接收消息了,根据消息的内容采取相应的操作。
服务端代码如下:

public class ConnectHelper
    {
        //私有成员
        private static byte[] result = new byte[1024];
        private int myProt = 500;   //端口  
        static Socket serverSocket;
        static Socket clientSocket;

        Thread myThread;
        static Thread receiveThread;

        //属性

        public int port { get; set; }
        //方法

        internal void StartServer()
        {
            //服务器IP地址  
            IPAddress ip = IPAddress.Parse("127.0.0.1");
            serverSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
            serverSocket.Bind(new IPEndPoint(ip, myProt));  //绑定IP地址:端口  
            serverSocket.Listen(10);    //设定最多10个排队连接请求  

            //Debug.WriteLine("启动监听{0}成功", serverSocket.LocalEndPoint.ToString());
            string message = string.Format("");

            //通过Clientsoket发送数据  
            myThread = new Thread(ListenClientConnect);
            myThread.Start();

        }

        internal void QuitServer()
        {
            serverSocket.Close();
            clientSocket.Close();
            myThread.Abort();
            receiveThread.Abort();
        }

        internal void SendMessage(string msg)
        {
            clientSocket.Send(Encoding.ASCII.GetBytes(msg));
        }


        /// <summary>  
        /// 监听客户端连接  
        /// </summary>  
        private static void ListenClientConnect()
        {
            while (true)
            {
                try
                {
                    clientSocket = serverSocket.Accept();
                    clientSocket.Send(Encoding.ASCII.GetBytes("Server Say Hello"));
                    receiveThread = new Thread(ReceiveMessage);
                    receiveThread.Start(clientSocket);
                }
                catch (Exception ex)
                {
                    Debug.WriteLine(ex.Message);
                }

            }
        }

        /// <summary>  
        /// 接收消息  
        /// </summary>  
        /// <param name="clientSocket"></param>  
        private static void ReceiveMessage(object clientSocket)
        {
            Socket myClientSocket = (Socket)clientSocket;
            while (true)
            {
                try
                {
                    //通过clientSocket接收数据  
                    int receiveNumber = myClientSocket.Receive(result);
                    string message = Encoding.ASCII.GetString(result, 0, receiveNumber);
                    //Debug.WriteLine("接收客户端{0}消息{1}", myClientSocket.RemoteEndPoint.ToString(), Encoding.ASCII.GetString(result, 0, receiveNumber));
                }
                catch (Exception ex)
                {
                    try
                    {
                        Debug.WriteLine(ex.Message);
                        myClientSocket.Shutdown(SocketShutdown.Both);
                        myClientSocket.Close();
                        break;
                    }
                    catch (Exception)
                    {

                    }

                }
            }
        }
    }

WPF应用初始化完成后开启Socket服务端:

public Helpers.ConnectHelper _connector;

        public MainWindow()
        {
            InitializeComponent();
            this.Loaded += MainWindow_Loaded;
            this.Closed += MainWindow_Closed;

            _connector = new Helpers.ConnectHelper();
            _connector.StartServer();
        }

之后Unity3D应用初始化完成后启动Socket客户端,并与服务端建立连接

public class NewBehaviourScript : MonoBehaviour
{
    const int _port = 500;
    private TcpClient _client;
    byte[] _data;
    string _error;

    // Use this for initialization
    void Start () {
        Init();
    }

    // Update is called once per frame
    void Update () {
        //按键盘上的上下左右键可以翻看模型的各个面[模型旋转]
        // 上
        if (Input.GetKey(KeyCode.UpArrow))
        {
            transform.Rotate(Vector3.right * Time.deltaTime * 10);
            SendMessage("up!");

        }
        // 下
        if (Input.GetKey(KeyCode.DownArrow))
        {
            transform.Rotate(Vector3.left * Time.deltaTime * 10);
            SendMessage("down!");

        }
        // 左
        if (Input.GetKey(KeyCode.LeftArrow))
        {
            transform.Rotate(Vector3.up * Time.deltaTime * 10);
            SendMessage("left!");

        }
        // 右
        if (Input.GetKey(KeyCode.RightArrow))
        {
            transform.Rotate(Vector3.down * Time.deltaTime * 10);
            SendMessage("right!");

        }

    }

    void OnGUI()
    {
        GUI.Label(new Rect(50, 50, 150, 50), _error);
    }

    void OnDestory()
    {
        _client.Close();
    }

    #region 通信初始化
    private void Init()
    {
        try
        {
            _client = new TcpClient();
            _client.Connect("127.0.0.1", _port);
            _data = new byte[_client.ReceiveBufferSize];
            SendMessage("Ready!");
            _client.GetStream().BeginRead(_data, 0, _client.ReceiveBufferSize, ReceiveMessage, null);
        }
        catch (Exception ex)
        {

            _error = ex.Message;
        }

    }
    #endregion

    #region 发送消息
    public new void SendMessage(string message)
    {
        try
        {
            NetworkStream ns = _client.GetStream();
            byte[] data = System.Text.Encoding.ASCII.GetBytes(message);
            ns.Write(data, 0, data.Length);
            ns.Flush();
        }
        catch (Exception ex)
        {
            _error = ex.Message;
        }
    }

    #endregion

    #region 接收消息
    private void ReceiveMessage(IAsyncResult ar)
    {
        try
        {
            _error = "";
            int bytesRead;
            bytesRead = _client.GetStream().EndRead(ar);
            if (bytesRead < 1)
            {
                return;
            }
            else
            {
                string message = System.Text.Encoding.ASCII.GetString(_data, 0, bytesRead);
                switch (message)
                {
                    case "up":
                        transform.Rotate(Vector3.right * 10);
                        break;
                    case "down":
                        transform.Rotate(Vector3.left * 10);
                        break;
                    case "left":
                        transform.Rotate(Vector3.up * 10);
                        break;
                    case "right":
                        transform.Rotate(Vector3.down * 10);
                        break;
                }
                _error = string.Format("{0}:{1}", DateTime.Now.ToString(), message);
                this._client.GetStream().BeginRead(_data, 0, System.Convert.ToInt32(this._client.ReceiveBufferSize), ReceiveMessage, null);
            }
        }
        catch (Exception ex)
        {
            _error = ex.Message;
        }

    }
    #endregion

}

客户端的启动是在Unity3D的一个脚本里完成的,这个脚本同时包含了通过键盘控制对象旋转的代码,将脚本挂载到游戏对象里,实现操控。
同时为了实现在WPF主程序中对Unity3D对象进行控制,在WPF主程序添加了几个按钮,点击按钮发送消息给Unity3D执行操作。

//向上旋转
        private void btnUp_Click(object sender, RoutedEventArgs e)
        {
            _connector.SendMessage("up");
        }
        //向下旋转
        private void btnDown_Click(object sender, RoutedEventArgs e)
        {
            _connector.SendMessage("down");
        }
        //向左旋转
        private void btnLeft_Click(object sender, RoutedEventArgs e)
        {
            _connector.SendMessage("left");
        }
        //向右旋转
        private void btnRight_Click(object sender, RoutedEventArgs e)
        {
            _connector.SendMessage("right");
        }

代码下载

在这个过程中也遇到了一些问题,比如Unity3D嵌入后无法单独操控(不能响应键盘/鼠标输入),试了多次,最终才有了一个相对折衷的方案。