美文网首页
MonoGame-DX 多线程多窗体 键盘输入bug

MonoGame-DX 多线程多窗体 键盘输入bug

作者: Kagamia | 来源:发表于2016-07-10 23:28 被阅读0次

    bug产生

    这个坑特别的有趣...这是发生在wcR2从xna3.1移植到MonoGame3.4上出现的一个微妙的bug。这是一个游戏场景仿真的程序,我使用了类似如下代码,使得在Winform中可以同时打开多个Game窗体:

    void btn_Click() {
      new Thread(()=> {
        new MyGame(args).Run();
      }).Start();
    }
    

    其中MyGame继承于Xna的Game类,Run()可以阻塞执行游戏的u/d循环,每个游戏窗体都能正常的捕获输入,这很OK。

    然而移植到了MonoGame就发生了灾难,同样的代码打开第一个MyGame窗体时一切正常,而打开了第二个窗体就无法正常捕获键盘输入,Keyboard.GetState()将不会返回任何按键的输入状态,即使关闭了第一个MyGame,或是关闭了所有的MyGame重新打开,依然所有的键盘输入无效。

    bug分析

    我创建了一个极简的测试用例来还原这个场景,代码如下:

    static void Main() {
      Form f = new Form();
      f.MouseClick += (o,e) => {
        new Thread(()=>new Game1().Run()).Start();
      };
      Application.Run(f);
    }
    
    class Game1 : Game {
      GraphicsDeviceManager graphics;
      public Game1() {
        graphics = new GraphicsDeviceManager(this);
      }
      protected override Draw(GameTime gameTime) {
        GraphicsDevice.Clear(Keyboard.GetState().IsKeyDown(Keys.A) && IsActive?
          Color.Black : Color.CornflowerBlue);
      }
    }
    

    程序入口点创建了一个窗体,当我点击窗体的时候会弹出一个新的游戏窗体,在游戏窗体中按A键会使背景清空为黑色,否则显示为默认的天蓝色。

    显然 如果不做任何处理,这段代码在MonoGame-WinDX中只有第一次打开的窗体可以正常响应输入。当然,使用WinGL的话会直接跳出一个多线程相关的错误。另外,这段代码在LinqPad中执行是完全无效,连第一个窗体都无法正常接受输入。

    如果我们对主函数换一种写法:

    static void Main() {
      new Game1().Run();
      new Game1().Run();
    }
    

    这样虽然两个游戏窗体是先后弹出(因为Run()的阻塞执行),但是前后两个窗体的键盘输入都会正确的响应。所以显然,在线程中创建Game是引发Bug的必要条件,并非第二个Game总会出现问题。

    但是为什么会这样呢?只能从MonoGame的源代码下手。

    我们先看看Keyboard.GetState()的实现,在MonoGame4.5.1版本后可能做了增强,原来的版本只有一句话:

    static List<Keys> _keys;
    
    public static KeyboardState GetState() {
      return new KeyboardState(_keys);
    }
    

    而这个静态的_keys是什么时候赋值的呢?祭出反编译神器.net Reflector分析,它大致是这样来的:(参考代码)

    class WinFormsGamePlatform : GamePlatform {
      private WinFormsGameWindow _window;
      private readonly List<XnaKeys> _keyState;
    
      public WinFormsGamePlatform(Game game) {
        _keyState = new List<XnaKeys>();
        Keyboard.SetKeys(_keyState);
        _window = new WinFormsGameWindow(this);
        _window.KeyState = _keyState;
        //......
      }
    }
    

    而这个类是在Game的构造函数中执行的,也就是说,每创建一个GameKeyboard._keys都会被刷新覆盖一次,并传递到GameWindow中,而GameWindow我们相对比较熟悉,它包含了所有的事件处理逻辑:参考代码

    internal WinFormsGameWindow(WinFormsGamePlatform platform) {
      //......
      // Use RawInput to capture key events.
      Device.RegisterDevice(UsagePage.Generic, UsageId.GenericKeyboard, DeviceFlags.None);
      Device.KeyboardInput += OnRawKeyEvent;
      //......
    }
    

    OnRawKeyEvent中实现了填充KeyState的代码。

    一句话结论:KeyboardState底层通过SharpDX.RawInput实现。至于如何实现,我们需要去翻阅SharpDX 2.6.x的快照:参考代码

    public static void RegisterDevice(UsagePage usagePage, UsageId usageId, DeviceFlags flags) {
      RegisterDevice(usagePage, usageId, flags, IntPtr.Zero);
    }
    

    IntPtr.Zero这个参数很重要。在重载中,他会传递到API原语的hwnd参数中,查阅MSDN文档获知,当传递IntPtr.Zero的时候,表示API将会捕获键盘当前焦点原文参照这里
    最后,这个函数会创建一个单例的messageFilter,挂载到Application或是MessageFilterHook内置字典中,filter将会捕获来自rawInput API的消息,传递给Device.HandleMessage()函数,经过分拣后通过Device.KeyboardInput事件发出。

    喵喵喵喵喵?我们总结一下这个事件传递顺序:

    //事件注册
    Game.ctor();
    GamePlatform.ctor();
    GameWindow.ctor();
    RawInput.Device.RegisterDevice();
    User32.RegisterRawInputDevices();
    
    //事件返回
    IMessageFilter.PreFilterMessage();
    RawInput.Device.HandleMessage();
    event RawInput.Device.KeyboardInput;
    GameWindow.OnRawKeyEvent();
    set Keyboard._keys;
    
    //表层逻辑调用 获取当前的键盘输入状态
    Keyboard.GetState();
    

    最后的嫌疑就很明确了:调用RegisterDevice的时候没有提供要捕获特定窗体的hWnd,导致在创建第二个Game时和之前的线程上下文不一致,导致默认单例的filter无法获取新窗体的键盘输入。

    bug消灭

    按照上面的思路,我们在Game.ctor中主动的对指定窗体调用RegisterDevice,并且创建自己的Filter,问题将会解决。修改Game1的代码如下:

    using SharpDx.RawInput;
    class Game1 : Game {
      GraphicsDeviceManager graphics;
      public Game1() {
        graphics = new GraphicsDeviceManager(this);
        //fix multi-thread keyboardState bug
        Device.RegisterDevice(UsagePage.Generic, UsageId.GenericKeyboard, DeviceFlags.None, Window.Handle, RegisterDeviceOption.NoFiltering);
        SharpDX.Win32.MessageFilterHook.AddMessageFilter(Window.Handle, new RawInputMessageFilter());
      }
    }
    //直接复制sharpDX的实现
    class RawInputMessageFilter : IMessageFilter {
      public virtual bool PreFilterMessage(ref Message m) {
        if (m.Msg == 0xff)
          Device.HandleMessage(m.LParam);
        return false;
      }
    }
    

    再次测试,问题解决。

    当然这种解决方法毫不优雅,在rawInput上挂接了多个filter可能会影响Keyboard中静态_keys的生成。不过实际表现好像不错。因为DeviceKeyboardInput事件广播效应,无论来自哪个窗体的rawInput消息都会“顺带”的传递到最新的窗体中,如果最新打开的窗体被关闭则会使这个方法失效,直到下一个窗体重新创建覆盖掉_keys才会恢复。

    所以还需要进一步的补救方法:不使用默认Keyboard.GetState(),用反射在每个Game实例中自己写一个替代方法,获取当前GameWindow的keys...嗯...反正你都要写InputManager,这也算是举手之劳了。

    最好的方法其实是发issue让官方解决啦...[doge][doge][doge]

    后日谈

    2016.7.14更新

    手欠还是测试了一下,即使反射了keyState,当最新的窗体关闭后,事件依然无法传递进来。所以合理的方法应该为在上面的基础上,重写Game.OnActivated方法,每次窗体焦点时重新执行RegisterDevice,才能基本解决。

    另外MonoGame中鼠标也要进行独立处理,好在这里有一个公开的静态重载Mouse.GetState(GameWindow),用它替代无参的GetState函数即可正常使用。

    相关文章

      网友评论

          本文标题:MonoGame-DX 多线程多窗体 键盘输入bug

          本文链接:https://www.haomeiwen.com/subject/sobdjttx.html