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

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函数即可正常使用。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,761评论 5 460
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,953评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,998评论 0 320
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,248评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,130评论 4 356
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,145评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,550评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,236评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,510评论 1 291
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,601评论 2 310
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,376评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,247评论 3 313
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,613评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,911评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,191评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,532评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,739评论 2 335

推荐阅读更多精彩内容

  • 1、窗体 1、常用属性 (1)Name属性:用来获取或设置窗体的名称,在应用程序中可通过Name属性来引用窗体。 ...
    Moment__格调阅读 4,428评论 0 11
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,497评论 18 139
  • 《裕语言》速成开发手册3.0 官方用户交流:iApp开发交流(1) 239547050iApp开发交流(2) 10...
    叶染柒丶阅读 25,317评论 5 18
  • 本文从 这里 翻译过来的。 2048这个游戏有一段时间特别火,Github上有其原始版本,游戏看起来很简单,但是...
    江枫阅读 1,428评论 2 7
  • 川端康成作品集《我在美丽的日本》一书将川端康成独特的风格:幻想的感觉、幽情的哀伤、玄妙的余韵,这种日本传统美的文学...
    煜烟阅读 918评论 0 0