VB6 想摸 DX9,卡人的从来不是语法。变量声明就那几种,Type、Function、Declare,翻来覆去;真正让人卡住的是“我该怎么跟一个 C 语言写的、以接口指针为核心的图形库对话”——DX9 没有为 VB6 提供任何官方绑定,你能查到的方法名、参数表全是 C++ 版本,落到 VB6 里每写一行都要先想清楚这一层是谁在替你做。这套系列做到第 9 篇,窗口、消息循环、高精度计时、贴图这些外围件已经在冷风引擎(CoolWind2D)里铺得差不多了,现在该回过头来把 DX9 那批真正干活的方法一个个摊开看。
这篇不打算写成 API 手册的翻译版。手册里CreateDevice有五个参数,但手册不会告诉你为什么窗口模式下的后台缓冲格式可以填D3DFMT_UNKNOWN,也不会告诉你DrawPrimitiveUP传进去的顶点坐标为什么要减 0.5,更不会告诉你窗口一最小化再还原、程序就黑屏的原因其实在Present的返回值里。这些都是把 2D 小游戏从“能跑”推到“能稳定跑”必须迈过去的坎。下面按调用顺序来:先说清楚 VB6 调 DX9 的路线问题,再说设备和参数,然后是一帧的完整链路,接着是 Sprite 和 Font 这两个高频方法,最后是设备丢失和 VB6 自身的语言坑。
1. VB6 调 DX9 没有官方绑定,这件事得先认下来
1.1 从 dx8vb 说起:VB6 的类型库停在 DirectX 8
很多人第一次在 VB6 里写 3D 或 2D 加速代码,会翻到一个叫dx8vb.dll的文件,也就是“DirectX 8 for Visual Basic”的类型库。引用它之后,DxVBLibA这个命名空间里会出现Direct3D8、Direct3DDevice8、D3DPRESENT_PARAMETERS这些类型,能直接在 VB6 的 IDE 里点出来,有智能提示,参数类型也对得上。这是官方给 VB6 的最后一套封装,属于 DirectX 8 时代的产物。
问题在于,DirectX 9 出来之后,这条路就断了。9 的组件没有对应的 VB 版本类型库,d3d9.dll对外只导出一个函数Direct3DCreate9,剩下的全得靠接口指针往下走。所以你在网上看到的“VB6 DX9”代码,本质上都是社区自己做的封装或者手工调用。这一点必须先认清,否则会一直在找一个不存在的官方入口。
提示:如果项目对性能要求没有到极限,用
DxVBLibA(D3D8 接口)做 2D 游戏完全够用,方法名和 DX9 高度对应,以后想迁移并不难。但既然这个系列标题定在 DX9,我们就按 DX9 的接口语义来讲。
1.2 三条路线摆在一起比:类型库、vtable 手调、中间层 DLL
VB6 要碰到 DX9 的接口方法,实际能走的路就三条,各自的代价差别很大。
| 路线 | 具体做法 | 上手难度 | 维护成本 | 适合场景 |
|---|---|---|---|---|
| 沿用 DX8 类型库 | 引用dx8vb.dll,用 D3D8 接口 | 低,有智能提示 | 低 | 纯 2D,追求开发速度 |
| vtable 手工调用 | Declare声明Direct3DCreate9,拿回指针后用偏移量调方法 | 高,需要记住每个方法在虚表中的位置 | 高,接口一升级全废 | 需要 DX9 特有功能 |
| 薄中间层 | 用 C 系语言写一个 DLL,导出 VB6 能直接调的函数 | 中,需要一次编译 | 低,VB6 侧很干净 | 长期维护的引擎 |
第一条路最省心,第二条路最硬核,第三条路最像“工程做法”。把这三条摆在一起,就能明白为什么大多数个人项目最后会走向第二条或第三条——不是不想省事,是 2D 游戏里有些效果(比如精确的纹理寻址、精灵批次合并、多显示器枚举)在 DX8 封装里做不干净。
1.3 冷风引擎为什么选了最“笨”的那条
冷风引擎最后选的是“vtable 手工调用 + 一层极薄的 VB6 类模块封装”。理由不复杂:这套引擎的目标是教学和源码可读,如果中间塞一个二进制 DLL,读者看不到内部逻辑,就失去了跟着写一遍的意义。而 vtable 调用虽然笨,但它把“接口指针到底是什么”这件事彻底暴露出来了,对理解 COM 和 DX9 反而有帮助。
具体做法大致是这样:先用Declare把导出函数声明进来,拿到IDirect3D9的指针,再按虚表顺序,用CallWindowProc或者等价的偏移调用方式去触达每一个方法。VB6 里这种写法看起来有点吓人,但套路固定以后,新增一个方法就是复制一段模板、改一下索引号。真正的难点不在调用本身,而在于参数怎么按 C 的约定压进去——结构体用ByRef传首地址,字符串用StrPtr,浮点数注意不要被 VB6 偷偷转换成双精度。
注意:如果你不打算研究底层,只想要一个能跑的 2D 游戏,别碰 vtable。老老实实用 DX8 类型库,两个小时能把画面画出来;研究 vtable 花掉的两周,换不来游戏本身的一行玩法逻辑。
2. CreateDevice 之前要算清楚的四笔账
2.1 Direct3DCreate9 只认一个数字:SDK 版本号
DX9 暴露给外部的入口函数少得可怜,核心就一个:
Private Declare Function Direct3DCreate9 Lib "d3d9.dll" _ (ByVal SDKVersion As Long) As Long参数SDKVersion在 C 头文件里是D3D_SDK_VERSION这个宏,实际值是32。这个数字不是随便填的,它起到版本校验的作用:运行时的d3d9.dll会拿这个值和自己的内部版本比对,对不上就直接返回 0(空指针),后面的所有调用全部崩溃。所以你会看到有些代码写着写着在别人机器上跑不起来,原因就是编译时用的 SDK 版本号和目标机器上的运行库不匹配。
更麻烦的是,这个宏在不同版本的 SDK 里是会变的。早期版本是 31,后来是 32,如果你抄了一份很老的教程代码,很可能拿到一个空指针还不自知,因为 VB6 不会告诉你“这是个空对象”,它只会在你下一步调方法的时候抛一个莫名其妙的错误。
我的做法是拿到返回值后立刻判空,并且把返回值打到调试输出里:
Dim pD3D As Long pD3D = Direct3DCreate9(32) If pD3D = 0 Then Debug.Print "Direct3DCreate9 失败,检查 d3d9.dll 是否可加载" Exit Sub End If这一步花不了几行代码,但能省掉后面大量的无效排查。
2.2 D3DPRESENT_PARAMETERS 十四个字段逐个过
拿到IDirect3D9之后,下一个动作是创建设备CreateDevice,而它最重的参数就是D3DPRESENT_PARAMETERS这个结构。DX9 版本一共十四个字段,全部是 4 字节:
Private Type D3DPRESENT_PARAMETERS BackBufferWidth As Long BackBufferHeight As Long BackBufferFormat As Long BackBufferCount As Long MultiSampleType As Long MultiSampleQuality As Long SwapEffect As Long hDeviceWindow As Long Windowed As Long EnableAutoDepthStencil As Long AutoDepthStencilFormat As Long Flags As Long FullScreen_RefreshRateInHz As Long PresentationInterval As Long End Type全部 4 字节的好处是:这个结构在 VB6 里天然就是 56 字节,和 C 侧完全一致,不需要做任何打包处理。这一点在 DX9 里是运气好,换到别的库就未必了。
逐个说关键的几个。BackBufferWidth和BackBufferHeight是后台缓冲的像素尺寸,窗口模式下一般等于客户区大小,注意是客户区,不含标题栏和边框,用ScaleWidth/ScaleHeight换算成像素再填。BackBufferFormat在窗口模式下可以直接填D3DFMT_UNKNOWN(0),让系统按桌面格式去选,这样能避免很多“全屏能跑、窗口黑屏”的怪问题;全屏模式下必须显式指定,常用的D3DFMT_X8R8G8B8是 22,D3DFMT_A8R8G8B8是 21,D3DFMT_R5G6B5是 23。
SwapEffect在 2D 场景里几乎无脑填D3DSWAPEFFECT_DISCARD(1)。这个值的含义是“交换后不保证后台缓冲内容”,驱动可以自由优化,是性能最好的一种。代价是每帧必须自己Clear,别指望上一帧的残留。Windowed填 1 表示窗口模式,填 0 表示全屏。PresentationInterval填D3DPRESENT_INTERVAL_IMMEDIATE(&H80000000)表示不等待垂直同步,填D3DPRESENT_INTERVAL_ONE(1)表示每帧等一次垂直同步。2D 小游戏如果画面出现横向撕裂,先改这个字段。
2.3 创建失败时,返回码比日志有用
CreateDevice返回的是HRESULT,VB6 里拿到的是一个Long。这里有个非常容易翻车的点:HRESULT的错误值都在&H80000000以上,而 VB6 的Long是有符号 32 位,所以这些常量在 VB6 里必须写成负数或者用十六进制字面量直接写——VB6 会把&H8876086C这样的字面量按补码解释成负数,这点和 C 不一样,但结果是对的。
常用的几个对照:
| 返回码 | 十进制/符号 | 含义与处置 |
|---|---|---|
D3D_OK | 0 | 成功 |
D3DERR_INVALIDCALL | &H8876086C | 参数非法,多半是结构没初始化或句柄为 0 |
D3DERR_NOTAVAILABLE | &H8876086A | 当前配置不支持,通常是格式或行为标志选错 |
D3DERR_OUTOFVIDEOMEMORY | &H8876017C | 显存不够,降低分辨率或缓冲数量 |
D3DERR_DEVICELOST | &H88760868 | 设备丢失,需要重建 |
我见过最常见的失败是D3DERR_INVALIDCALL,原因八成是hDeviceWindow填了一个无效句柄,或者BackBufferCount填了 0(窗口模式下至少要 1)。排查的时候不要瞎猜,把返回码打出来,对着表查,比看十篇帖子都快。
还有一个隐含的前提:BehaviorFlags的选择。2D 游戏基本用不到硬件顶点处理,直接填D3DCREATE_SOFTWARE_VERTEXPROCESSING(&H20)最稳。这个值告诉运行时“顶点变换交给我自己算”,避免了不同显卡驱动对XYZRHW顶点格式支持不一致的问题。如果你填了硬件顶点处理,某些老驱动会在DrawPrimitiveUP上直接返回失败——而这个错误只有在真正画东西的时候才暴露,创建阶段完全看不出来。
3. 一帧画面从 Clear 到 Present 到底发生了什么
3.1 帧循环骨架与三个必须成对的方法
DX9 的渲染是一个严格的“开始—绘制—结束”结构,任何一次绘制都必须夹在BeginScene和EndScene之间,否则DrawPrimitive系列全部静默失败。这条规则看起来简单,但它带来一个很实际的约束:绘制指令不能跨帧累计,每一帧都要重新组织。
一个最小的帧循环长这样:
Do While bRunning If PeekMessage(msg, 0, 0, 0, PM_REMOVE) Then If msg.message = WM_QUIT Then Exit Do TranslateMessage msg DispatchMessage msg Else RenderFrame End If LoopRenderFrame内部的结构是固定的三步:
pDevice.Clear 0, ByVal 0, D3DCLEAR_TARGET, &HFF202020, 1#, 0 pDevice.BeginScene ' ... 所有绘制指令 ... pDevice.EndScene pDevice.Present ByVal 0, ByVal 0, 0, ByVal 0Clear的第一个参数是矩形数量,填 0 表示清整个视口;第三个参数D3DCLEAR_TARGET(1)表示清颜色缓冲,如果开了深度缓冲还要或上D3DCLEAR_ZBUFFER(2)。第四个参数是填充色,注意 DX9 的颜色是A8R8G8B8排列,&HFF202020是深灰,如果你按RGB()的直觉写成&H000000FF想要红色,画出来会是蓝色——这个坑几乎每个人都踩过一次。
Present是把后台缓冲翻到前台的动作,2D 游戏里四个参数通常全填 0,让它按D3DPRESENT_PARAMETERS里的配置走。但它的返回值必须检查,因为设备丢失就是从这里冒出来的。
提示:
Clear每帧都要调。用了D3DSWAPEFFECT_DISCARD之后后台缓冲内容是不确定的,不Clear会出现画面拖影和花屏,这种花屏还不是稳定复现,非常难查。
3.2 为什么 2D 只需要 XYZRHW 加一组 UV
3D 渲染的顶点要经过世界矩阵、视图矩阵、投影矩阵三次变换,最后才落到屏幕上。2D 游戏根本不需要这套东西,它要的是“我说这个像素在哪就在哪”。DX9 为此保留了XYZRHW这个顶点格式,其中RHW是“齐次 W 的倒数”,填 1.0 就表示已经变换过了,跳过所有矩阵运算。
于是 2D 顶点结构可以简化到 28 字节:
Private Type TLVERTEX X As Single Y As Single Z As Single RHW As Single Color As Long U As Single V As Single End Type对应的 FVF 是D3DFVF_XYZRHW Or D3DFVF_DIFFUSE Or D3DFVF_TEX1,数值上等于&H004 Or &H040 Or &H100,也就是&H144。这个常量要随结构一起传给SetFVF,一旦填错,显卡就会按错误的字节偏移去读你的顶点,表现出来是画面完全错乱或者什么都不显示。
一个带纹理的四边形就是两个三角形、六个顶点:
pDevice.SetFVF FVF_TLVERTEX pDevice.SetTexture 0, pTexture pDevice.DrawPrimitiveUP D3DPT_TRIANGLELIST, 2, verts(0), Len(verts(0))SetTexture的纹理阶段号和采样器状态在 DX9 里是分开的,2D 场景只用第 0 级,够用。DrawPrimitiveUP里的UP意思是“用户指针”,顶点数据直接从 VB6 数组里取,不需要预先创建顶点缓冲。对一个 2D 引擎来说这是最省事的做法,缺点是每帧都要把数据从内存拷到显卡,顶点数量大了会有明显开销。冷风引擎的做法是:背景层、精灵层用小批量 UP 绘制,几百个粒子以上才切换到真正的顶点缓冲,这样既避免了复杂管理,又不至于在粒子多的时候掉帧。
3.3 半像素偏移:贴图发虚和多出半条边的真正来源
这是 DX9 2D 绘制里最容易被忽略、又最影响观感的一个细节。
DX9 的屏幕坐标系里,像素中心不在整点上,而在整点加 0.5 的位置。也就是说,屏幕左上角第一个像素占据的区域是[0,1],但它的“中心”是(0.5, 0.5)。当你用XYZRHW直接把一个顶点坐标写成0(整数)时,光栅化规则会认为这个顶点正好落在两个像素的边界上,采样结果是各取一半,表现就是边缘出现半透明的过渡,整个画面像蒙了一层雾。
解决办法是给所有顶点坐标减去 0.5:
verts(0).X = x - 0.5: verts(0).Y = y - 0.5 verts(1).X = x + w - 0.5: verts(1).Y = y - 0.5 verts(2).X = x + w - 0.5: verts(2).Y = y + h - 0.5这个 0.5 一旦加上,边缘立刻变得锐利,贴图的像素点能严格一对一映射到屏幕像素。
与之配套的还有纹理坐标的问题。如果你按整数像素取 UV,也就是u = px / texWidth,同样会引入半像素误差,导致图集里相邻的精灵串色。正确做法是在 UV 上也补半个像素:u = (px + 0.5) / texWidth。这两处 0.5 加起来,才算把 2D 的像素对齐真正做干净。
我自己踩这个坑的过程挺有代表性:一开始以为是纹理过滤的问题,把D3DTEXF_POINT换来换去没用;后来怀疑是分辨率不匹配,把窗口调成整数倍还是一样;最后拿一张纯色带边框的测试图,放大八倍看截图,才看出每条边都多了一条半透明的线——这时候才反应过来是坐标没偏移。
4. Sprite 与 Font:2D 引擎里调用频率最高的两个接口
4.1 Begin、Draw、End 三件套以及它背后的渲染状态切换
ID3DXSprite是 DX9 给 2D 场景准备的高层封装,它的价值不在于“能画图”(DrawPrimitiveUP也能),而在于它把一批精灵的顶点攒起来、一次性提交,并且自动处理了纹理阶段和混合状态的设置。
用法是三段式:
pSprite.Begin D3DXSPRITE_ALPHABLEND pSprite.Draw pTexture, ByVal 0, ByVal 0, ptPos, &HFFFFFFFF pSprite.EndBegin的参数决定它内部怎么帮你配状态。D3DXSPRITE_ALPHABLEND(&H10)会开启 Alpha 混合,适合带透明通道的 PNG 图;D3DXSPRITE_OBJECTSPACE(&H4)表示保留你自己设置的变换矩阵,不加这个它会把世界矩阵重置成单位矩阵。2D 游戏最常用的是两者或上。
关键规则只有一条:Begin和End必须严格配对,且不能嵌套。End的时候它会把攒下的顶点一次性提交,同时恢复它改动过的渲染状态。如果你在Begin之后直接调用了DrawPrimitiveUP画别的东西,那批精灵的顶点就会和你的顶点混在一起,画出来顺序全乱——这种 bug 表现出来是“某些精灵偶尔跑到最前面”,很难定位。
还有一个性能细节:一次Begin/End之间画的精灵越多,批次合并的效果越好,但每批能用的纹理只有一张。所以引擎里通常按纹理分组,同一张图集的精灵排在一起画。冷风引擎没有做自动排序,而是把这个责任交给调用方,通过一个“按图集分桶”的简单策略解决,代码量小、效果也够。
4.2 纹理加载参数:尺寸、格式、透明色与 Alpha 通道
D3DXCreateTextureFromFileEx是加载纹理的主力方法,参数多达十三个,但真正需要调的没几个:
D3DXCreateTextureFromFileEx pDevice, sFile, _ D3DX_DEFAULT, D3DX_DEFAULT, 1, 0, D3DFMT_UNKNOWN, _ D3DPOOL_MANAGED, D3DX_DEFAULT, D3DX_DEFAULT, _ ColorKey, ByVal 0, pTextureMipLevels填 1 是 2D 游戏的标准做法,因为精灵几乎不会被缩小显示,多级渐远纹理纯属浪费显存。Pool填D3DPOOL_MANAGED(1)让运行时自己管显存和内存的同步,设备丢失后只需要重新加载,不用手动重建每一张图。
ColorKey是给不带 Alpha 通道的位图用的透明色。比如一张 BMP 背景是纯品红&HFF00FF,把这个值传进去,加载时就会把那部分变成全透明。这里有个颜色顺序的坑:ColorKey用的是A8R8G8B8,而 VB6 的RGB()返回的是B8G8R8顺序并塞在Long的低 24 位里,直接用RGB(255,0,255)传给ColorKey会变成完全不同的颜色,抠图失败。必须转换:(&HFF000000 Or (r * &H10000) Or (g * &H100) Or b)。
对 PNG 这类带 Alpha 通道的图,ColorKey填 0 就好,透明信息由通道本身提供。但要记得把混合状态设对:
pDevice.SetRenderState D3DRS_ALPHABLENDENABLE, 1 pDevice.SetRenderState D3DRS_SRCBLEND, D3DBLEND_SRCALPHA pDevice.SetRenderState D3DRS_DESTBLEND, D3DBLEND_INVSRCALPHAD3DBLEND_SRCALPHA是 5,D3DBLEND_INVSRCALPHA是 6。这两个值是预乘和非预乘的分水岭:如果你的 PNG 是普通非预乘格式,用这两个就对了;如果是预乘 alpha 的图,源混合要填D3DBLEND_ONE(2),否则边缘会发黑。
还有一条经验:纹理尺寸尽量取 2 的幂。虽然 DX9 支持非 2 的幂纹理,但部分老显卡会拒绝创建,或者在采样时强制拉伸。图集做 512×512、1024×1024,是最省心的选择。
4.3 DrawText 的矩形约定和字体缓存
文字绘制走ID3DXFont,创建时把字体名、字号、粗细一次设定好:
D3DXCreateFont pDevice, 14, 0, 400, 1, 0, 0, 0, 0, 0, "宋体", pFont第二个参数是字高,0表示按默认宽度自动计算;第四个参数是粗细,400是常规,700是加粗。这些参数和 GDI 的CreateFont是一套语义,用过 GDI 的话上手很快。
DrawText的矩形参数是引用传递,而且要小心它会被修改:
Dim rc As RECT rc.Left = 10: rc.Top = 10: rc.Right = 300: rc.Bottom = 40 pFont.DrawText sText, Len(sText), rc, DT_LEFT Or DT_VCENTER, &HFFFFFFFF第三个参数在 VB6 里其实要传字符串指针,正确的写法是ByVal StrPtr(sText)。直接传字符串,VB6 会做一次 Unicode 到 ANSI 的转换,过程中产生的临时缓冲区生命周期不可控,运气不好就是乱码或者崩溃。这是 VB6 调 API 的通用规则,不只是 DX9。
DT_VCENTER要和DT_SINGLELINE一起用才生效,这是 GDI 继承下来的老规矩,单独写DT_VCENTER什么都不做。另外Right和Bottom建议比内容宽出一点,文字在矩形内换行时如果宽度卡得太死,会出现最后一个字被截断的情况。
性能上,ID3DXFont内部会缓存字形纹理,所以不要每帧创建和释放字体对象。冷风引擎里把字体对象挂在引擎实例上,整个生命周期只创建一次,绘制开销能压到很低。这一点和Sprite不一样,Sprite对象虽然也建议复用,但每帧Begin/End是必需的。
5. 最小化回来后画面变黑:设备丢失的完整排查链
5.1 TestCooperativeLevel 的两个返回码语义完全不同
窗口被最小化、被别的全屏程序抢占、显示器切换分辨率,这些操作都会让 D3D 设备进入“丢失”状态。此时所有绘制调用都会失败,Present返回D3DERR_DEVICELOST。
处理设备丢失的标准入口是TestCooperativeLevel,它有三个返回值,含义差别很大:
| 返回值 | 含义 | 处置 |
|---|---|---|
D3D_OK | 正常 | 继续渲染 |
D3DERR_DEVICELOST | 设备丢失,但暂时无法恢复 | 跳过本帧,等待 |
D3DERR_DEVICENOTRESET | 设备丢失,但已经可以重置 | 执行Reset并重建资源 |
很多人把这两个错误码混为一谈,看到非零就立刻Reset,结果在最小化期间疯狂调用Reset并且每次都失败,CPU 占用飙升。正确的做法是:拿到DEVICELOST就什么都不做直接返回,下一帧再来问;只有拿到DEVICENOTRESET才动手。
一个稳妥的帧循环结构是这样:
Dim hr As Long hr = pDevice.TestCooperativeLevel If hr = D3DERR_DEVICENOTRESET Then If Not RecreateDevice() Then Exit Sub ElseIf hr <> D3D_OK Then Exit Sub ' 设备不可用,本帧直接跳过 End IfRecreateDevice里先释放所有D3DPOOL_DEFAULT的资源,调Reset(参数和创建时的D3DPRESENT_PARAMETERS一致),成功之后再重建资源。D3DPOOL_MANAGED的纹理不用管,它们由运行时自动恢复。
5.2 Reset 前后的资源释放顺序,错一步就白屏
Reset有一个硬性前提:所有以D3DPOOL_DEFAULT创建的资源必须先释放,否则Reset直接返回失败。这些资源包括:默认池的纹理、顶点缓冲、索引缓冲,以及通过CreateAdditionalSwapChain创建的后备链。
顺序错了的表现很有迷惑性:Reset调用返回失败,但代码里没检查,继续往下画,于是屏幕上什么都没有——不是黑屏,是连Clear的颜色都不显示,因为设备还处于丢失状态。
我建议的做法是把“创建资源”和“释放资源”写成两个独立过程,Reset的前后各调一次:
ReleaseDefaultPoolResources hr = pDevice.Reset(params) If hr <> D3D_OK Then Exit Function RebuildDefaultPoolResources这里的关键是每一个Create都要有对应的Release,VB6 没有析构函数,忘记Release不会报错,只会在Reset时报一个不相关的错误码。我的习惯是在类模块里维护一个数组,创建时入列,释放时逐个出列,虽然土,但不会漏。
5.3 我误判过的两种情况
第一种:窗口最小化后还原,画面是白的,但程序没崩。一开始我以为是设备没恢复,查了半天Reset逻辑。后来发现是Clear的颜色填反了——&HFFFFFFFF是白色,我本意想要黑色。这种低级错误在排查设备问题时特别容易被忽略,因为“白屏”和“设备失效”在直觉上是同一类现象。
第二种:全屏切换回来之后,鼠标位置的判定全部偏移。原因是切换分辨率时客户区尺寸变了,但引擎里缓存的宽高还是旧值,XYZRHW的顶点按旧尺寸算,自然全错。修法是在Reset之后重新读一次客户区尺寸,并把所有依赖尺寸的缓存(视口、投影、UI 布局)全部刷新。这件事提醒我:设备重建不只是重建纹理,任何依赖屏幕尺寸的计算结果都要作废。
第三种更隐蔽:某些集成显卡在窗口被遮挡时不会主动触发设备丢失,但Present会返回成功、画面却停住。这种情况没法通过返回值判断,只能靠计时器兜底——如果连续若干帧的绘制耗时异常长,就主动TestCooperativeLevel查一次。
6. VB6 自身的几个坑,比 DX9 更难缠
6.1 结构体、Pack 与 Long 的符号位
VB6 的自定义Type在内存里是顺序排列的,默认按 4 字节对齐。D3DPRESENT_PARAMETERS全是Long,天然对齐,不用操心。但一旦结构里混入了Single、Byte、Integer,就必须计算填充。
举个例子:D3DVERTEXBUFFER_DESC这类结构里有Integer和Long混排,如果你按字面顺序直接写进Type,VB6 会按自己的规则补齐字节,而 C 侧可能用#pragma pack(1)紧凑排列,两边就对不上了。表现出来是读出来的字段值全是乱的。
解决办法有两个:一是在Type里手工插入Padding(0 To N) As Byte把空隙填满,让它和 C 的布局一致;二是避开这类结构,用逐个字段的查询函数代替。冷风引擎对不确定的结构一律采用第二种,牺牲一点性能,换取确定性。
还有个 VB6 特有的坑:Long是有符号的,&HFFFFFFFF在 VB6 里等于 -1。当你需要传一些高位为 1 的常量(比如D3DPRESENT_INTERVAL_IMMEDIATE是&H80000000)时,它会被解释成-2147483648。传参时没问题,但如果代码里拿它做比较或者取绝对值,就会出意料之外的结果。
6.2 VarPtr、StrPtr、CopyMemory 能做什么不能做什么
VB6 里没有指针类型,但有几个隐藏函数可以拿到地址:VarPtr拿变量地址,StrPtr拿字符串数据区地址,ObjPtr拿对象指针。这三个都是运行时提供的,声明时要小心:
Private Declare Sub CopyMemory Lib "kernel32" Alias "RtlMoveMemory" _ (ByRef Destination As Any, ByRef Source As Any, ByVal Length As Long)CopyMemory的ByRef ... As Any是关键,它允许你把任何变量直接传进去,VB6 会自动取地址。但如果你不小心写成ByVal,传进去的就是变量的值被当成地址用,直接访问违例。
StrPtr主要用于给 API 传字符串。DX9 的接口方法在 VB6 封装层里通常要求ByVal StrPtr(s),这样传的是 ANSI 字符串的首地址,不会再做编码转换。用ByVal s也能编译通过,但运行时可能崩溃——因为 VB6 内部用的是 Unicode,自动转换出来的临时缓冲区在调用返回后立刻失效。
还有一个使用边界:VarPtr拿到的数组地址只在数组未被ReDim期间有效。如果你在渲染循环里频繁ReDim顶点数组,之前拿到的地址就是野指针。冷风引擎的做法是预分配一个足够大的数组,用计数器控制有效长度,避免运行时重分配。
6.3 DoEvents 混进渲染循环之后
VB6 的窗口消息处理依赖DoEvents,很多教程在渲染循环里塞一个DoEvents,理由是“防止界面卡死”。这个做法在小 Demo 里看不出问题,一旦帧率上去就会暴露三个毛病。
第一个是帧率不稳。DoEvents会把控制权交还给消息泵,如果此时恰好有一批消息(比如鼠标移动产生的WM_MOUSEMOVE),处理时间不可控,帧时间就会出现尖峰。表现出来是动画一卡一卡的,但你查渲染代码找不到原因。
第二个是重入。DoEvents期间如果用户点了关闭按钮,Form_Unload会在渲染循环的内部被执行,此时你的设备和资源正在使用中,释放顺序全乱,结果就是退出时崩溃。
第三个是性能。DoEvents本身的调用开销在千赫兹级别,看起来不大,但它会打断 CPU 的指令流水,实测下来在 60 帧的循环里能吃掉 10% 以上的时间。
正确做法是用PeekMessage自己取消息,只有真正有消息的时候才TranslateMessage/DispatchMessage,没有消息就直接渲染。这段代码在前面 3.1 里已经给过了,核心是PM_REMOVE(1)这个标志和If PeekMessage(...) Then ... Else 渲染的分支结构。
如果确实需要在渲染过程中响应某些即时输入(比如按键),不要用DoEvents,改用GetAsyncKeyState直接查询键盘状态,它是非阻塞的,也不会打乱消息顺序。冷风引擎的输入模块就是这么实现的,把键盘、鼠标状态每帧采样一次,形成一份快照给游戏逻辑用,既避免了消息重入,又保证了同一帧内输入状态的一致性。
这套东西写到第 9 篇,我自己最大的体会是:DX9 的 API 本身并不复杂,真正消耗时间的是那些“文档里不写、出错时不报”的隐性约定——半像素偏移、颜色字节序、资源池的生命周期、Begin/End的配对、HRESULT的符号位。这些坑每一个单独拿出来都很小,凑在一起就足以让一个能跑的程序变得不可靠。
如果后面还要继续往下做,我打算把精灵的批次合并再收紧一点,按纹理分桶之后做一次排序,这样同屏几百个精灵的时候能少一半的DrawCall;另外设备丢失的处理目前还是手写的,考虑封装成一个状态机,让调用方完全不用关心Reset的细节。这两件事都不难,难的是先把现在这套跑稳——毕竟引擎的每一层都是踩在前一层的坑上才立起来的。