Managed Direct3D开发经验浅析
注意:本文主要针对Managed DirectX(MDX) 1.1,而不是MDX2.0或XNA
Question 1. 如何正确地安装Managed DirectX(MDX)运行库?
- 对于开发者,先装好vs2005,然后再安装最新的DirectX SDK即可,SDK会自动安装MDX运行库;
- 对于使用者,并没有必要安装SDK,仅需正确地安装.Net Framework 2.0 Redist Pack;然后再安装最新版的DirectX Redist Pack即可,不过要注意安装次序,后者需检测到前者存在才会安装MDX运行库
Question 2. 如何有效地调试Managed Direct3D(MD3D)程序?
- 本地DirectX的开发经验很重要,MDX其实只是前者的托管封装框架。不要天真地认为MDX很容易使用,它并不那么傻瓜化,而且文档也不充分, 甚至很不准确;它掩盖了很多必要细节,有时还会返回一系列莫名的错误,所以千万不要被它误导了思维;
- 调试MD3D程序时,一定要打开Direct3D调试版本运行库的消息喷涌: 找到DirectX控制面板,在Direct3D页中将"Debug output level"设置成"More",选中"Use Debug version of Direct3D",然后确定即可;
- 去下载一个DebugView, 它是sysinternals.com提供的一个系统调试信息输出工具,这样你便能看到D3D调试库的消息喷涌了。在很多情况下,MDX框架内部就是出了错它也不会向你汇报任何信息的;这时,DebugView将是你最宝贵的调试信息来源,记得将你的程序修改到没有任何D3D性能警告、系统错误和内存泄漏输出为止!
- 最后,在通过文档和系统报错都理解不了MDX的行为时,不要犹豫,用Reflector大胆地拆吧,MDX框架其实并不复杂
Question 3. 在Managed Direct3D开发中该如何处理显示设备丢失(Device Lost)的情况?
处理设备丢失这个问题比较麻烦,一来它的再现具有不确定性,二来它更多地体现为框架性的问题,而不是单个API的使用问题,因此多数书籍和tutorial对其语焉不详,要么干脆避而不谈;连M$也没有给出个足够清晰的实例,它的那个DXUT框架考虑得问题太多,因此远不够知文见意。这里我并不打算铺开来谈DXUT对应的托管实现,也不打算谈D3D设备和什么是设备丢失这些基本概念,我假设你已具备一定的经验,我们将抛开一些次要的方面,直奔主题。我希望通过这篇文章(有点长...),能够填补目前网上关于MDX中如何处理设备丢失这一问题结论性的空白。
首先让我们来澄清一下几个关键的细节问题,然后我将给出一段功能完善同时又足够简洁的可运行示例。
Question 3.1. Device类提供了哪些关键事件?
Device类提供了DeviceLost、DeviceReset、DeviceDisposing和DeviceResizing等4个关键事件。
DeviceLost设备丢失事件主要发生在:
- • Device.Present内部,检查到设备丢失、抛出DeviceLostException异常之前
- • Device.Reset内部,调用本地方法IDirect3DDevice9::Reset之前
- • Device.Dispose内部,在DeviceDisposing事件触发之前
从框架角度,该事件一般用来销毁所有与设备相关的"易失性"D3D资源,如分配在显存中的顶点缓冲、索引缓冲、贴图和D3D字体等等。注意,在1次设备丢失、恢复的过程中,DeviceLost有可能不止1次地被调用,MDX框架将触发其视为一种"确保清场"的保险手段,因此只要觉得有必要,MDX框架便会毫不犹豫地触发它,而不管其调用是不是冗余的。一个良好的开发习惯是,遵照COM编程规范,在DeviceLost事件中销毁的对象,统统将其引用设为null,并添加相应的判断,以避免重复销毁"已销毁"了的资源。
DeviceReset设备恢复事件主要发生:
- • Device.Reset内部成功地调用了本地方法IDirect3DDevice9::Reset之后
该事件一般用来再次创建所有的"易失性"D3D资源。尽管它的触发场景远不及DeviceLost事件那么多,但考虑到MDX框架的复杂性,为了能够100%地避免资源的重复创建(以防万一),在其中添加必要的条件判断是一个非常好的编程习惯,比方说,只有当某VertexBuffer的引用不为null时,方重新创建之。
DeviceDisposing设备销毁事件主要发生:
- • Device.Dispose方法内部,在真正地调用本地化方法销毁device之前
该事件多用于在当前D3D设备被销毁之前销毁所有相关的D3D资源。由DeviceLost的讨论可知,事实上,在DeviceDisposing触发之前,DeviceLost事件就已经被Device.Dispose()触发过一次了,因此实际上这个事件有点多余,在多数情况下用不着为其提供专门的实现,把所有销毁资源的代码全写在DeviceLost事件里便OK了。
DeviceResizing事件我们在讲述处理窗口Resize事件时再谈。
Question 3.2. 类静态变量Device.IsUsingEventHandlers究竟是啥?它很重要吗?
这是一个非常关键的参数,因为它和它所代表的自动事件布线机制几乎是所有MDX设备丢失相关问题的罪魁祸首。
首先说说什么是MDX自动事件布线机制。在MDX框架设计依始,MDX Team便意识到了处理设备丢失等一系列框架问题的复杂性,他们决定在MDX框架中引入一系列关联事件触发机制,以"傻瓜化"的方式提供给用户使用。举个例子,当Device.IsUsingEventHandlers为true时,所有新的VertexBuffer对象在创建时,均将会把自己的Created事件和Disposing事件自动地hook到Device的Reset和Disposing事件链中,这样,当device设备丢失并恢复时,device将自动地调用所有hooked VertexBuffer的对应事件,从而销毁并重建所有以Pool.Default为标志创建的VertexBuffer,而用户所需要做的只是正确地提供其相应事件的实现即可。
这一貌似很personal touch的机制其实很让人窝火,原因有二:其一,由于这一机制的相关描述文档很缺乏,这令VertexBuffer、IndexBuffer、Mesh和Texture等资源类里面的Created、Dispoing等事件看起来莫名其妙,让人搞不清楚其触发时机和设计目的是什么;其二,自动事件布线将有可能极大地降低MDX程序的运行效率,你很有可能在一帧的render过程中无意识地创建应用了若干个临时性质的资源对象(其方式有可能非常隐蔽),如Surface等,但却忘记了(或者根本没有意识到要)显式地Dispose,这样麻烦就来了,所有临时对象都往device上那么一挂,一个游戏每秒60帧,每帧生成个7、8个临时对象,而且全都被device事件链所引用,因此其资源将全部都不能通过垃圾收集得以Dispose,玩它个2小时,想想机器是不是还撑得起?某些MDX程序在关闭时速度奇慢,或者是越运行越慢,其主要就是由上述原因导致的。对于实时性及性能要求相当高的游戏开发而言,提供有副作用的傻瓜化手段来提高开发效率不应该是第一设计考虑,而MDX Team显然有点犯昏,尤其是一些与通用的托管程序开发原则(如无需关心内存释放等)相抵触的地方在文档中并没有明确说清楚,这是该打板子的。
另一方面,IsUsingEventHandlers的用法也很诡异,其初始默认值为true,但如果你不想用它的话,你必须在new一个device实例之前(而不是之后)将其设为false,否则就会导致device处于不稳定的工作状态。M$的文档中曾提到,可以通过运行时修改这个参数来动态地控制自动事件处理的激活与关闭,不过老实说我可不敢那么做,多数情况下也没这个必要,这里我不多说,自己用reflector拆了看吧。
此外,这标志的名字也堪称一绝,IsUsingEventHandlers,这EventHandlers究竟是指device自己的events呢,还是那些自动hook上去的资源类的?文档中没有说,这里我总结一下:IsUsingEventHandlers主要针对后者,即主要用来控制hooked events的触发;而device自身的DeviceLost、DeviceReset和DeviceDisposing等3个事件是不受这个标志控制的,无论其值为true还是false,上述3个关键事件总是会在适当的时候被MDX框架所调用;最后值得特别指出的是,device的DeviceResizing这一个事件比较特殊,其触发与否直接受到IsUsingEventHandlers值的影响,这与hooked events类似。非常混乱的感觉,不是么?
OK,该谈谈怎么避免上述一团糟的局面了,最简洁有效的办法便是永远都不要使用Device所提供的自动事件布线机制,在程序一开始就把IsUsingEventHandlers设成false!这样我们在处理设备丢失时,便可以抛开D3D资源类中那一堆莫名其妙的事件和DeviceResizing,仅关注于DeviceLost、DeviceReset和DeviceDisposing三个事件即可。实际上,DXUT采用的就是这种方式。
Question 3.3. 如何确定性地触发设备丢失事件?
调试设备丢失处理流程的关键在于如何确定地触发设备丢失。其方法很多,我认为最简单最保险的方法按Ctrl+Alt+Del弹出锁定计算机界面(记得在控制面板中关闭winxp的欢迎屏幕和快速用户切换哟),使用这种方法时,无论是窗口MDX程序还是全屏MDX程序都能确定地抛出设备丢失异常,而不会出现像最小化/最大化窗口那样时而丢失时而又不丢失的情况。
在通常的设备丢失场合中,我们仅需在适当的时刻Reset一下Device、然后重建所有"易失性"D3D资源,即可恢复设备正常。但在某些极具"破坏性"的场合下,我们只能首先销毁Device,然而再从头彻底重建整个设备和资源,方可实现设备恢复,举个例子,当某窗口MD3D程序还在运行时,我们强制地改变其桌面色深或分辨率,从而导致设备丢失,这种情况便属于后者。与前者相比,后者只不过是多了一个判断设备丢失原因、然后销毁Device的处理过程而已。本文给出的源代码中只考虑了普通的设备丢失情况,而后面一种较为复杂的情况就交给大家做练习吧。
Question 3.4. 应该在哪里集中处理设备丢失异常?
从理论上来讲,设备丢失异常可能发生在任何D3D函数调用的过程中,这不利于开发人员对其处理流程的集中控制。对此,M$在D3D运行库内部对设备丢失异常进行处理的过程中,采用了一种"抑制报错"的潜在设计规则,即多数函数并不会因设备丢失而立即显式地返回失败或异常,而是假装什么都没发生一样正常地返回,但其整个操作实际上是无效的。这是一种特殊的设计行为。M$建议我们在调用Device.Present时对设备丢失异常进行集中的处理,所有上述行为都是为了同一个目的而服务的:即我们只需检查Device.Present调用有没有抛出设备丢失异常即可,而不必草木皆兵。
而另一方面,考虑到当我们在尝试恢复设备时,可能再次地碰到设备丢失的情况,因此除了Present之外,我们还必须考虑在Device.Reset调用处对设备丢失异常进行集中的处理。在后面的代码中我们将会清晰地看到这点。
Question 3.5. 在轮询设备丢失状态时,该使用TestCooperativeLevel还是CheckCooperativeLevel?
显然是后者,因为TestCooperativeLevel方法是以抛出异常方式返回轮询状态的,而这比CheckCooperativeLevel直接返回状态码来得更为低效。实际上,在实时性要求较高的程序中,我们应当尽可能地避免使用托管异常,以防止不必要的性能损耗。
Question 3.6. 我们该如何正确地处理窗口Resize事件?
当然,这里我们讨论的是窗口化的MD3D程序。简单来说,对于窗口Resize事件存在着两种处理行为,一种是MDX框架的默认行为:即不改变D3D几何流水线的变换过程,直接将投影表面拉伸后BitBlt到窗口上,比方说,我们在Perspective Projection的过程中采用的是4:3的横纵比,设备的BackBuffer为400x300象素,可MDX却将其表面拉伸之后BitBlt到100x600的客户区上去了,这显然会导致画面的模糊和变形;为避免上述情况,我们可以采用另外一种方式,即通过自己编写代码来改变上述默认行为,方法很简单:当每次窗口尺寸变化之后,根据调整之后的客户区尺寸重新设置BackBuffer的大小,然后Reset一下设备即可。详见下面的示例源码。
这里要注意3件事,其一,由于已经关闭了自动事件布线,DeviceResizing事件将不会被触发,因此我们简单地忽略它即可;其二,由于Device.Reset会触发所有D3D资源的销毁和重建,因此显然我们只有在确定Resize操作完成之后才能进行Reset,而不能在Resizing的过程中频繁进行Reset,这样机器吃不消;其三,切记在调用device.reset的时候要注意考虑设备丢失的情况。
Question 3.7. 有没有足够清晰的完整实例?
这里我给出一个足够清晰的示例,它是我在Craig Andera的D3D教程基础上改写的,其源码我已经发给了作者本人。别看它小(这个"小"是相对的...从框架角度来说,已经非常简洁了),这段代码在应付设备丢失时表现得非常的健壮,除了强制改变桌面色深之外(我故意忽略了的,留给大家练习吧),我几乎玩不死它,也许你能玩死它,如果你成功了,请第一时间告诉我:)
using System.Drawing;
using System.Windows.Forms;
using System.Diagnostics;
using System.Threading;
using Microsoft.DirectX;
using Microsoft.DirectX.Direct3D;
namespace Tutorial_MDX
{
Render Loop相关
窗口化MDX程序主体
}
Question 4. 我们该选用哪一种Render Loop结构?
目前在MDX中可以使用的Render Loop结构总共有三种:
- OnPaint方式:在Form的OnPaint事件中进行所有的Render操作,然后在退出OnPaint之前调用一下Form.Update(), 这相当于向消息队列再次地发送了一个WM_PAINT,从而实现周而复始的OnPaint事件触发,以替代Render Loop。这种方式的缺点在于多了消息投送、分拣和处理这么一层处理,因此性能代价很高,速度最慢,而且冗余的堆内存分配最多;其优点在于,它与普通Win32程序的消息循环机制兼容性最好,多用于实现窗口化的MD3D程序,在某些场合下,这种方式将是唯一的选择
- OnApplicationIdle方式:就是在上面的实例中我所采用的那种方式,优雅、简洁、高效,而且不会引入额外的堆分配,DXUT也采用了这种方式,不多说了,自己看代码,这是目前性能最高的MDX Render Loop
- DoEvent方式:另一种最为常用的DoEvent方式,很简单,即在main()中插入类似代码即可。其优点在于简单;而缺点则在于DoEvents()方法本身不够优化,它在运行过程中临时数据的内存分配量很大,MDX Team的Tom Miller指出,在1秒钟60次的高频调用场合下,DoEvents将导致第一代GC堆中的垃圾容量迅速增长,这将不可避免地增加垃圾收集器的调用频率,从而降低程序的实时性能。
while (app.Created)
{
app.Render();
Application.DoEvents();
}
Question 5. 如何提高类似MDX游戏的实时托管程序的性能?
这里我直接给出重点。CLR Performance Team的Rico Mariani指出,实验表明,实时程序性能所受到的最大冲击则主要来源于第二代垃圾收集的过程,相比之下,JIT过程和第一代GC收集过程对性能的影响则不那么严重,因为前者仅相当于一次性的固定性能损耗,而后者与第二代GC收集相比也是快如闪电,因此并不会对托管程序的实时性能起到决定性的作用。
基于上述,在优化MDX程序时,我们无需对临时分配的小对象和长期存储的大对象过度敏感,但必须特别关注那些有可能从第一代GC进入第二代的对象,并严格控制其对象生存期,尽量避免第二代堆的增长(推荐一个小工具,CLR Profile for .Net Framework 2.0,它可以很方便地查看托管堆的分配记录);对于实时性要求很高的托管程序而言,生存时间长不长短不短的对象最可怕,这是一条分析和优化的黄金准则,必须牢记在心。
其实从上面我们可以看出,实现高性能的MDX程序还是有一定难度的。MDX商业游戏现在并不多见,我认为,目前MDX最大的价值在于它提供了一个便于学习和进行技术研讨的试验平台,如帮助我们快速地实现3D技术验证原型等,而不是一种用于商业软件的开发工具;事实上,与XNA相比,MDX最为难能可贵之处在于它与Native DirectX概念与接口的相似性:它不仅为我们提供了较高的原型开发效率,同时还为我们保留了快速迁徙到Native DirectX、从而榨干机器性能的机会;相比之下,接口经过全新设计的XNA显然不具这方面的优势,因此在短期之内,XNA存在的现实意义似乎并不那么明显,尤其是在人们普遍对托管程序性能还不那么了解的情况下,它在PC游戏开发中的特色依然有待于进一步体现。