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的构造函数中执行的,也就是说,每创建一个Game,Keyboard._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的生成。不过实际表现好像不错。因为Device的KeyboardInput事件广播效应,无论来自哪个窗体的rawInput消息都会“顺带”的传递到最新的窗体中,如果最新打开的窗体被关闭则会使这个方法失效,直到下一个窗体重新创建覆盖掉_keys才会恢复。
所以还需要进一步的补救方法:不使用默认Keyboard.GetState(),用反射在每个Game实例中自己写一个替代方法,获取当前GameWindow的keys...嗯...反正你都要写InputManager,这也算是举手之劳了。
最好的方法其实是发issue让官方解决啦...[doge][doge][doge]
后日谈
2016.7.14更新
手欠还是测试了一下,即使反射了keyState,当最新的窗体关闭后,事件依然无法传递进来。所以合理的方法应该为在上面的基础上,重写Game.OnActivated方法,每次窗体焦点时重新执行RegisterDevice,才能基本解决。
另外MonoGame中鼠标也要进行独立处理,好在这里有一个公开的静态重载Mouse.GetState(GameWindow),用它替代无参的GetState函数即可正常使用。
网友评论