《WINDOWS 环境下32位汇编语言程序设计》第7章 图形操作(1)
图形设备接口GDI(Graphics Device Interface)是Win32的一个重要组成部分,其作用是允许Windows的应用程序将图形输出到计算机屏幕、打印机或其他输出设备上。GDI实际上是一个函数库,包括直线、画图和字体处理等数百个函数。
7.1 GDI原理
Windows是基于图形界面的,所以在Win32编程中,图形操作是最常用的操作。GDI的意义在于将程序对图形界面的操作和硬件设备隔绝开来,在程序中可以将所有的图形设备都看成是虚拟设备,包括视频显示器和打印机等,然后通过GDI函数用同样的方法去操作它们,由Windows负责将函数调用转化成针对具体硬件的操作。只要一个设备提供了和Windows兼容的驱动程序,它就可以被看做是一个标准的设备。以前在DOS系统下写应用程序的时候,如果要进行图形操作,那么就要考虑到市场上每种显示卡的不同,否则在装配某种显卡的计算机上就可能无法正常运行,对汇编程序员来说,这真是一个噩梦。在Win32编程中,正是GDI函数让这个噩梦成为历史。
GDI函数全部包括在GDI32.DLL中,在编程的时候,注意要在源程序的开头加上相应的包含语句:
include gdi32.inc
includelib gdi32.lib
与GDI相关内容的规模真是太庞大了,只要查看一下gdi32.inc文件就可以发现,函数的总数达到了300多个,与GDI相关的数据结构也非常多,要完全深入GDI编程,用上本书的全部篇幅可能也不够。在本章中,笔者希望通过几个例子,让读者能了解GDI的原理和基本的使用方法。
归纳起来,GDI操作可以从3个方面去了解——When,Where和How:
● When——指的是进行图形操作的时机,究竟什么时刻最适合程序进行图形操作呢?在7.1.1节“GDI程序的结构”中,将探讨这个问题。
● Where——指的是图形该往哪里画,既然Windows隔离了硬件图形设备,那么该把什么地方当做“下笔”的地方呢?7.1.2节的“设备环境”就是解答。
● How——了解了上面两个问题后,最后还要知道“如何画”,这就涉及如何使用大部分GDI函数的问题了,在本章余下来的篇幅中,将集中讨论这个问题。
7.1.1 GDI程序的结构
1.客户区的刷新
正如上面所说的,本节讨论的是“When”的问题,读者可能会问:为什么会有这个问题,如果要向窗口输出图形,程序想在什么时候输出那就是什么时候,难道这个时刻还有规定不成?
但这个问题似乎不能这样来问,让我们来考虑这些情况:在DOS操作系统中编程的时候,程序把文字或图形输出到屏幕,在输出新的内容之前,这些内容总是保留在屏幕原处,这些内容会被意外覆盖的唯一情况是激活一个TSR程序(Terminate and Stay Resident 的缩写,中文常翻译为 “终止并驻留”程序),但TSR程序在退出之前有义务恢复原来的屏幕,如果它无法恢复屏幕的内容,那么这是它的责任,我们不会在自己的程序中去考虑屏幕内容会无缘无故消失这种情况,所以可以把屏幕看成是应用程序私有的。
如果程序输出的内容过多,如用dir显示一个含有很多文件的目录,用户根本无法看清快速上翻的屏幕,这时程序可以设计一个参数来暂停一下,如dir /p。这已经是DOS程序最“体贴”的做法了,如果用户想回过头去看已经滚出屏幕的内容,那可对不起,只能再执行一遍了!
所以对DOS程序来说,程序想在什么时候输出信息那就是什么时候,根本不存在When这个问题。
但在Windows操作系统中,屏幕是多个程序“公用”的,用户程序不要指望输出到窗口中的内容经过一段时间后还会保留在那里,它们可能被别的东西覆盖,如其他窗口、鼠标箭头或下拉的菜单等。在Windows中,恢复被覆盖内容的责任大部分属于用户程序自己,理由很简单:Windows是个多任务的操作系统,假如程序B覆盖了程序A的窗口内容,覆盖掉的内容由程序B负责恢复的话,它就必须保存它覆盖掉的内容,但是在它将保存的内容恢复之前,程序A也在运行,并可能在程序B恢复以前已经向它自己的窗口输出新的内容,结果当程序B恢复它保存的窗口内容时,保存的内容可能是过时的(而DOS的情况就不同,TSR程序激活的时候,用户程序是被挂起的),所以最好的办法就是让程序A自己来决定如何恢复。
Windows系统采用的方法是:当Windows检测到窗口被覆盖的地方需要恢复的时候,它会向用户程序发送一个WM_PAINT消息,消息中包括了需要恢复的区域,然后由用户程序来决定如何恢复被覆盖的内容。
如果程序因为忙于处理其他事务以至于无法及时响应WM_PAINT消息,那么窗口客户区原先被覆盖的地方可能会被Windows暂时画成一块白色(或者背景色)的矩形,或者根本就是保留被覆盖时的情形,直到程序有时间去响应WM_PAINT消息为止。我们常常可以看到这种情况发生在死锁程序的客户区内,这就是因为死锁的程序无法响应WM_PAINT消息来恢复客户区造成的。
所以对于“When”这个问题,答案是:程序应该在Windows要求的时候绘画客户区,也就是在收到WM_PAINT消息的时候。如果程序需要主动刷新客户区,那么可以通过调用InvalidateRect等函数引发一条WM_PAINT消息,因为在WM_PAINT消息中刷新客户区的代码是必须存在的,所以用这种看似“舍近求远”的办法实际上可以节省一份重复的代码。即使是在游戏程序这种“主动刷新”远远多于“被动刷新”的程序中,只要窗口有被其他东西覆盖的可能,那么这个原则就是适用的。
2.GDI程序的结构
对于Win32程序来说,WM_PAINT消息随时可能发生,这就意味着,程序再也不能像在DOS下一样输出结果后就不管了,反过来,程序在任何时刻都应该知道如何恢复整个或局部客户区中以前输出的内容,本着这个要求,可以按图7.1所示来安排程序结构。
图7.1 GDI程序的结构
如果程序的功能比较简单,可以采取图中左边的A程序结构,即计算及刷新整个客户区的代码全部安排在WM_PAINT消息中完成,这样,每次当客户区的全部或部分需要被更新的时候,程序重新执行整个生成客户区屏幕数据的功能模块并刷新客户区。这种结构适用于功能模块很短小且执行速度很快的情况,整个过程的时间最好不超过几百ms,否则,用户会在一个明显的等待时间后才看到程序把客户区中的“空洞”补上。考虑一个极端的情况:当程序输出的内容是经过千辛万苦才算出来的——这不是一件奇怪的事情,计算圆周率的程序就要动辄计算几个小时——那么即使客户区被别的窗口覆盖掉一点点,程序也要经过整个计算过程后才能重画客户区,而且在这个过程中,程序还没有从WM_PAINT消息返回,以至于无法处理其他消息,结果程序就会以客户区中有个空洞的难看姿势呆在屏幕上一动不动达几个小时!
当生成屏幕数据的功能模块有些复杂的时候,如刚才计算圆周率的例子,就应该考虑采用图中B程序所示的结构了。在这个程序中,功能模块和客户区刷新模块分别在不同的子程序中实现,功能模块单独用一个子程序完成,这个子程序可以由用户通过选择菜单项在WM_COMMAND消息中执行,也可以新建另外一个线程来完成,总之,它最后把计算结果放到一个缓冲区中,而每当客户区需要刷新时,程序在WM_PAINT消息中调用客户区刷新子程序,这个子程序从计算好的缓冲区中取出数据并输出到客户区中,由于单纯的屏幕刷新过程是很快的,所以用户根本来不及看到客户区中的空洞。
在本章后面的内容中有两个时钟的例子:Clock.exe和BmpClock.exe,前面一个例子采用的是A结构,后面一个例子采用的是B结构,读者在阅读的时候可以比较一下它们在结构上的不同。
3.探讨WM_PAINT消息
当客户区被覆盖并重新显示的时候,Windows并不是在所有的情况下都发送WM_PAINT消息,下面是几种不同的情况:
● 当鼠标光标移过窗口客户区,以及图标拖过客户区这两种情况,Windows总是自己保存被覆盖的区域并恢复它,并不需要发送WM_PAINT消息通知用户程序。
● 当窗口客户区被自己的下拉式菜单覆盖,或者被自己弹出的对话框覆盖后,Windows会尝试保存被覆盖的区域并在以后恢复它,如果因为某种原因无法保存并恢复的话,Windows会发送一个WM_PAINT消息通知程序。
● 别的情况造成窗口的一部分从不可见变到可见,如程序从最小化的状态恢复、其他的窗口覆盖客户区后移开、用户改变了窗口的大小和用户按动滚动条等,在这些情况下,Windows会向窗口发送WM_PAINT消息。
● 一些函数会引发WM_PAINT消息,如UpdateWindow,InvalidateRect,以及InvalidateRgn函数等。
窗口过程收到WM_PAINT消息后,并不代表整个客户区都需要被刷新,有可能客户区被覆盖的区域只有一小块,这个区域就叫做“无效区域”,程序只需要更新这个区域。
与WM_TIMER消息类似,WM_PAINT消息也是一个低级别的消息,虽然它不会像WM_TIMER消息一样被丢弃,但Windows总是在消息循环空的时候才把WM_PAINT放入其中,实际上,Windows为每个窗口维护一个“绘图信息结构”,无效区域的坐标就在其中,每当消息循环空的时候,如果Windows发现存在一个无效区域,就会放入一个WM_PAINT消息。
无效区域的坐标并不附带在WM_PAINT消息的参数中,在程序中有其他方法可以获取,WM_PAINT消息只是通知程序有个区域需要更新而已,所以Windows也不会同时将两条WM_PAINT消息放入消息循环,当Windows要放入一条WM_PAINT消息的时候,如果发现已经存在一个无效区域了,那么它只需要把新旧两个无效区域合并计算出一个新的无效区域就可以了,消息循环中还是只需要一条WM_PAINT消息。
由于存在“无效区域”这样一个机制,所以程序在WM_PAINT消息中对客户区刷新完毕后工作并没有结束,如果不使无效区域变得有效,Windows会在下一轮消息循环中继续放入一个WM_PAINT消息。也正是因为Windows仅仅根据是否存在“无效区域”来决定是否发送WM_PAINT消息,而不是根据程序是否执行了刷新过程,所以程序也可以不去刷新客户区,而是简单地用一个ValidateRect函数直接让客户区变得有效,以此来“欺骗”Windows已经没有无效区域了,当Windows检查“绘图信息结构”的时候发现没有了无效区域,也就不会继续发送WM_PAINT消息了。
WM_PAINT消息的处理流程一般是:
.if eax == WM_PAINT ;eax为uMsginvoke BeginPaint,hWnd,addr stPS;刷新客户区的代码invoke EndPaint,hWnd,addr stPSxor eax,eaxret
读者可以发现中间并没有调用ValidateRect来使无效区域变得有效,这是因为BeginPaint函数和EndPaint函数隐含有这个功能,如果不是以BeginPaint/EndPaint当做消息处理代码的头尾的话,那么在WM_PAINT消息返回的时候就必须调用ValidateRect函数。
BeginPaint函数的第二个参数是一个绘图信息结构的缓冲区地址,Windows会在这里返回绘图信息结构,结构中包含了无效区域的位置和大小,绘图信息结构的定义如下:
PAINTSTRUCT STRUCThdc DWORD ?fErase DWORD ?rcPaint RECT <>fRestore DWORD ?fIncUpdate DWORD ?rgbReserved BYTE 32 dup(?)PAINTSTRUCT ENDS
其中hdc字段是窗口的设备环境句柄(在下一节中将要讲到),rcPaint字段是一个RECT结构,它指定了无效区域矩形的对角顶点,fErase字段如果为非零值,表示Windows在发送WM_PAINT消息前已经用背景色擦除了无效区域,后面3个字段是Windows内部使用的,应用程序不必去理会它们。
7.1.2 设备环境
好了,解决了“When”的问题,让我们来考虑一个新的问题,在DOS操作系统中,向屏幕输出数据实际上是把输出内容拷贝到视频缓冲区中,在第1章的图1.1中就已经说明:如果在文本模式下显示信息,只需要把内容拷贝到B8000h处的内存中;显示图形信息,可以把图形数据拷贝到A0000h处的内存中。
在Windows中,GDI接口把程序和硬件分隔开来,在Win32编程中,再也不能通过直接向视频缓冲区拷贝数据的办法来显示信息了,那么,究竟该往哪里输出图形呢——这就是“Where”的问题。答案是:通过“设备环境”来输出图形。
1.什么是设备环境
在Windows中,所有与图形相关的操作都是用统一的方法来完成的(不然就不能称为“图形设备接口”了)。不管是绘画屏幕上的一个窗口,还是把图形输出到打印机,或者对一幅位图进行绘画,使用的绘图函数都是相同的,为了实现方法上的统一,必须将所有的图形对象看成是一个虚拟的设备,这些设备可能有不同的属性,如黑白打印机和彩色屏幕的颜色深度是不同的,不同打印机的尺寸和分辨率可能是不同的,绘图仪只支持矢量而不支持位图等。不同设备的不同属性就构成了一个绘图的“环境”,就像DOS操作系统中把视频缓冲区当做图形操作的对象一样,这个绘图的“环境”就是Win32编程中图形操作的对象,一般把它叫做“设备环境”。设备环境实际上是一个数据结构,结构中保存的就是设备的属性,当对设备环境进行图形操作的时候,Windows可以根据这些属性找到对应的设备进行相关的操作。
在实际使用中,通过“设备环境”可以操作的对象很广泛,除了可以是打印机或绘图仪等硬件设备外,也可以是窗口的客户区,包括大大小小的所有可以被称为窗口的按钮与控件等的客户区,也可以是一个位图。总之,任何需要用到图形操作的对象都可以通过“设备环境”进行绘图。
为了更好地理解“设备环境”是什么,先来看一个例子,例子的代码在所附光盘的Chapter07\DcCopy目录中,DcCopy.asm中的代码如下:
;DcCopy.asm
;测试设备环境的代码,将一个窗口 DC 对应的象素拷贝到另一个窗口中
;使用 nmake 或下列命令进行编译和链接:
;ml /c /coff DcCopy.asm
;Link /subsystem:windows DcCopy.obj
.386
.model flat,stdcall
option casemap:none ;include 文件定义
include C:/masm32/include/windows.inc
include C:/masm32/include/gdi32.inc
includelib C:/masm32/lib/gdi32.lib
include C:/masm32/include/user32.inc
includelib C:/masm32/lib/user32.lib
include C:/masm32/include/kernel32.inc
includelib C:/masm32/lib/kernel32.lib ID_TIMER equ 1;数据段
.data?
hInstance dword ?
hWin1 dword ?
hWin2 dword ?
.const
szClass1 byte 'SourceWindow', 0
szClass2 byte 'DestWindow', 0
szCaption1 byte '请尝试用别的窗口覆盖本窗口!', 0
szCaption2 byte '本窗口图像拷贝自另一窗口', 0
szText byte 'Win32 Assembly, Simple and powerful !', 0.code
;定时器过程
_ProcTimer proc hWnd, uMsg, idEvent, dwTime local @hDc1, @hDc2 local @stRect:RECT invoke GetDC, hWin1 mov @hDc1, eax invoke GetDC, hWin2 mov @hDc2, eax invoke GetClientRect, hWin1, addr @stRect invoke BitBlt, @hDc2, 0, 0, @stRect.right, @stRect.bottom, \@hDc1, 0, 0, SRCCOPY invoke ReleaseDC, hWin1, @hDc1invoke ReleaseDC, hWin2, @hDc2 ret
_ProcTimer endp ;窗口过程
_ProcWinMain proc uses ebx edi esi, hWnd, uMsg, wParam, lParam local @stPs:PAINTSTRUCT local @stRect:RECT local @hDc mov eax, uMsg mov ecx, hWnd .if eax == WM_PAINT && ecx == hWin1 invoke BeginPaint, hWnd, addr @stPsmov @hDc, eax invoke GetClientRect, hWnd, addr @stRect invoke DrawText, @hDc, addr szText, -1, \addr @stRect, DT_SINGLELINE or DT_CENTER or DT_VCENTER invoke EndPaint, hWnd, addr @stPs .elseif eax == WM_CLOSE invoke PostQuitMessage, NULL invoke DestroyWindow, hWin1 invoke DestroyWindow, hWin2 .else invoke DefWindowProc, hWnd, uMsg, wParam, lParam ret .endif xor eax, eax ret
_ProcWinMain endp _WinMain proc local @stWndClass:WNDCLASSEX local @stMsg:MSG local @hTimer invoke GetModuleHandle, NULL mov hInstance, eax invoke RtlZeroMemory, addr @stWndClass, sizeof @stWndClass invoke LoadCursor, 0, IDC_ARROW mov @stWndClass.hCursor, eax push hInstance pop @stWndClass.hInstance mov @stWndClass.cbSize, sizeof WNDCLASSEX mov @stWndClass.style, CS_HREDRAW or CS_VREDRAWmov @stWndClass.lpfnWndProc, offset _ProcWinMain mov @stWndClass.hbrBackground, COLOR_WINDOW + 1 mov @stWndClass.lpszClassName, offset szClass1 invoke RegisterClassEx, addr @stWndClass invoke CreateWindowEx, WS_EX_CLIENTEDGE, offset szClass1, offset szCaption1, \WS_OVERLAPPEDWINDOW, \450, 100, 300, 300, \NULL, NULL, hInstance, NULL mov hWin1, eax invoke ShowWindow, hWin1, SW_SHOWNORMAL invoke UpdateWindow, hWin1 ;------------------------------------------------------mov @stWndClass.lpszClassName, offset szClass2 invoke RegisterClassEx, addr @stWndClass invoke CreateWindowEx, WS_EX_CLIENTEDGE, offset szClass2, offset szCaption2, \WS_OVERLAPPEDWINDOW, \100, 100, 300, 300, \NULL, NULL, hInstance, NULL mov hWin2, eax invoke ShowWindow, hWin2, SW_SHOWNORMAL invoke UpdateWindow, hWin2 ;设置定时器invoke SetTimer, NULL, NULL, 100, addr _ProcTimer mov @hTimer, eax ;消息循环.while TRUE invoke GetMessage, addr @stMsg, NULL, 0, 0.break .if eax == 0invoke TranslateMessage, addr @stMsginvoke DispatchMessage, addr @stMsg .endw ;清除定时器invoke KillTimer, NULL, @hTimer ret
_WinMain endp main proc call _WinMain invoke ExitProcess, 0
main endp
end main
这个程序的代码用到的大部分知识都是前面各章已经讲到的,在_WinMain中,用一个同样的窗口类建立了两个窗口,由于两个窗口属于同一个窗口类,所以它们的窗口过程都是_ProcWinMain,为了关闭任何一个窗口都可以结束程序,WM_CLOSE消息中用DestroyWindow函数摧毁了两个窗口。程序设置了一个周期为100 ms的定时器,Windows会每隔100 ms调用_ProcTimer子程序。在_ProcTimer中,将其中一个窗口的客户区拷贝到另一个窗口的客户区中,方法是通过GetDC获取窗口的DC句柄,并用BitBlt函数完成拷贝工作(这些函数的具体用法在下面的内容中会讲到),所以在右边的窗口显示了一句“Win32 Assembly, Simple and powerful!”,左边的窗口中也会出现这句话。
这个程序能演示出什么效果来呢?图7.2就是程序运行的结果,屏幕上的两个并排的正方形窗口就是DcCopy程序建立的窗口,程序每100 ms将右边窗口的客户区拷贝到左边的窗口客户区中,通过左边窗口的客户区就可以了解右边客户区DC对应的究竟是什么内容。
图7.2 DcCopy程序的运行结果
现在用其他程序将右边窗口客户区的一部分覆盖掉,通过左边窗口的变化可以惊奇地发现:右边窗口客户区的内容并不是程序自己输出到客户区的那句文本,而是以客户区为矩形区域的屏幕上我们真正看到的内容,它竟然包括其他窗口覆盖在上面的部分。这就意味着,扫雷游戏和纸牌游戏通过自己客户区对应的设备环境画图形,图形数据竟然画到了DcCopy窗口客户区对应的设备环境中。
这个例子验证了“设备环境”只是“环境”而不是“设备”,它并不存储发给它的图形数据,图形数据透过它写到了它所描述的“设备”上,每个窗口客户区的“设备环境”对应的设备都是屏幕,但由于它们在位置上可能重叠,所以向一个窗口的客户区写数据相当于同时写了下层窗口的客户区。
为了让当前激活的窗口在视觉上保持在最上面,下层窗口向自己客户区写的内容首先要经过Windows的“过滤”,只有没有被其他窗口覆盖掉的部分才真正被写到了屏幕上。
读者应该时刻提醒自己——“设备环境”只是一个环境,是设备属性的一组定义,程序输出的图形数据透过“设备环境”被定向到了具体的设备上,“设备环境”本身并不存储这些数据(在这里也可以看出Device Context中Context一词的含义:设备环境的上面是应用程序,下面是具体设备,而它是用来“联系上下关系”用的)。
读者可能认为:屏幕上的窗口就像放在桌面上的一张张纸,虽然一张纸可能暂时被另一张遮住,但纸上写的内容还是存在的,移开另一张纸就可以再次露出来。但实际情况是:桌面更像一个用粉笔写的公告黑板,一个窗口相当于划了一块空间写告示,写另一个告示的时候要把老告示的内容擦去一部分以便写新的内容,擦去的内容也就不存在了,如果要恢复老告示,那么必须把擦去的部分重新写上去。
2.获取设备环境句柄
要想对任何设备绘图,首先必须获取设备的“设备环境句柄”(hDC),几乎所有的GDI函数的操作目标都是hDC,在程序中得到一个hDC有几种方法。
最常用的方法是在WM_PAINT消息中用BeginPaint函数得到hDC,WM_PAINT消息的代码结构一般是:
.if eax == WM_PAINT ;eax为uMsginvoke BeginPaint,hWnd,addr stPS;刷新客户区的代码invoke EndPaint,hWnd,addr stPSxor eax,eaxret
BeginPaint函数的返回值就是需要刷新区域的hDC。要注意的是:BeginPaint返回的hDC对应的尺寸仅是无效区域,无法用它绘画到这个区域以外的地方去。由于窗口过程每次接收WM_PAINT消息时的无效区域可能都是不同的,所以这个hDC的值仅在WM_PAINT消息中有效,程序不应该保存它并把它用在WM_PAINT消息以外的代码中。基于同样的道理,BeginPaint和EndPaint函数只能用在WM_PAINT消息中,因为只有这时候才存在无效区域。
程序中常常有这种需求,就是在非WM_PAINT消息中主动绘画客户区,由于BeginPaint和EndPaint函数必须在WM_PAINT消息中使用,所以这时必须用另外的方法获取hDC,可以使用以下的方法:
invoke GetDC,hWnd ;获取hDC
;返回值是hDC
;绘图代码
invoke ReleaseDC,hWnd,hDc ;释放hDC
GetDC函数返回的hDC对应窗口的整个客户区,当使用完毕的时候,hDC必须用ReleaseDC函数释放。对于用GetDC获取的hDC,Windows建议使用的范围限于单条消息内,当程序在处理某条消息的时候需要绘画客户区时,可以用GetDC获取hDC,但在消息返回前,必须用ReleaseDC将它释放掉,如果在下一条消息中需要继续用到hDC,那么必须重新用GetDC函数获取。
上面两种方法获取的hDC都是窗口的hDC,如果要操作的是其他的对象,如打印机、位图等,就不能使用BeginPaint或GetDC函数了。当绘图的对象是一个设备的时候,可以用Create DC函数来建立一个DC:
invoke CreateDC,lpszDriver,lpszDevice,lpszOutput,lpInitData
lpszDriver指向设备名称,如显示设备的设备名是DISPLAY,打印机的设备名一般为WINSPOOL,下面这几句代码建立的DC对应整个屏幕:
szDriver db "DISPLAY",0...invoke CreateDC,addr szDriver,NULL,NULL,NULLmov hDC,eax
当绘图对象是位图的时候,同样需要一个与位图句柄相联系的DC,这时可以用函数CreateCompatibleDC来创建一个显示表面仅存在于内存中的DC:
invoke CreateCompatibleDC,hDc
参数中的hDC是用来参考的DC句柄,如果指定的参数是NULL,那么建立的DC将和当前屏幕的设置兼容,为了用CreateCompatibleDC建立的DC绘制一个位图,还需要用SelectObject函数将hDC和位图句柄联系起来。在这之后,通过hDC进行的绘图操作会将像素数据更新到位图中。
用CreateDC和CreateCompatibleDC函数建立的hDC在使用结束以后,必须用DeleteDC函数删除,注意这里不能用ReleaseDC,这个函数是和GetDC配合用的。
用BeginPaint/EndPaint,以及GetDC获取的hDC的使用时间不能超出本条消息,与此相比,用CreateDC,以及CreateCompatibleDC建立的hDC就没有这个限制,可以在任何时刻建立它并且一直使用到不再需要为止。
7.1.3 色彩和坐标
1.Windows中的色彩
可以表示的颜色总数由颜色深度决定,也就是存储每个像素所用的位数,各种显示设备可以显示的颜色总数可能大不相同,如果设备支持的颜色深度太浅,就会影响到图像的质量,会让人看起来觉得很粗糙和不自然。
一种颜色可以分解成红、绿、蓝三原色,所以可以用红、绿、蓝3个分量的组合来表示各种颜色。
当设备支持的颜色深度少于等于8位时(如8位(256色)、4位(16色)、2位(4色)或1位(2色)),总体位数太少,不足以用来表达3个颜色分量,这时系统建立一个色彩表,像素数据用来做索引在色彩表中获取颜色值,所以低于8位的颜色称为索引色。
只有当颜色深度大于8位的时候,像素数据中才直接包含红、绿、蓝3个分量。当颜色深度为16位的时候,红、绿、蓝各用5位表示,剩下的1位用做属性位,实际可以表示的颜色数目为215=32768种,16位深度的彩色又称为16位色、高彩色或增强色。当颜色深度为24位的时候,3个分量各用8位表示,实际可以表示的颜色数目为224=16777216种,24位深度的彩色又称为24位色、16M色或真彩色。对于人的双眼来说,超过16位的颜色就已经很难分辨了。
在Win32的编程中,统一使用32位的整数来表示一个深度为24位的颜色,在这32位中只使用低24位,每一种原色分量占用8位,其中0~7位为红色,8~15位为绿色,16~23位为蓝色。在程序中用到一种颜色常数的时候,可以如下使用:
mov eax,红色+绿色*100h+蓝色*10000h ;将颜色放入eax中
当显示设备无法表示24位色的时候,Windows会自动用设备可以显示的最接近的颜色来代替它,当显示设备的颜色深度比较低的时候,可以通过函数GetNearestColor来得知一种颜色(dwColor)会被系统替换成哪种颜色:
invoke GetNearestColor,hDC,dwColor ;返回真正使用的颜色值
但是当显示设备颜色深度太低的时候,经过Windows自动转换的图像可能会让人觉得很不自然,所以在有些时候,程序员可能希望预先得知设备的颜色深度,然后根据具体情况显示不同的图形。
显示设备的颜色深度可以用以下函数获取:
invoke GetDeviceCaps,hDC,PLANES
mov ebx,dwPlanes
invoke GetDeviceCaps,hDC,BITSPIXEL
mul ebx
mov dwColorDepth,eax
第一个函数调用返回DC的色彩平面数,第二个函数调用返回每个像素的色彩位数,颜色深度最后可以通过dwPlanes乘以dwBitsPixel得到。、
2.Windows中的坐标系
要用GDI函数绘图,就必须首先了解这些函数使用的坐标系,在默认的状态下,Windows坐标系以左上角作为坐标原点,以右方当做X坐标的正方向,以下方当做Y坐标的正方向。坐标的数值用一个有符号的16位数来表示,范围从-32768~32767,坐标的单位为像素,如图7.3所示。这种坐标系定义方法的好处是:窗口中每一点的坐标不会因为窗口的大小改变而改变,试想一下,如果以数学中通常的表示方法,以左下角作为坐标原点,那么当窗口高度被用户调整的时候,客户区中每一点的Y坐标都会变化,在具体使用中就会有诸多不便。
图7.3 Windows中的默认坐标系
但是Windows也提供了其他的一些坐标映射方法供程序员使用,可以用SetMapMode函数来为一个DC设置新的坐标映射方法:
invoke SetMapMode,hDC,iMapMode
可以设置的参数包括坐标原点、坐标的逻辑单位和坐标的正方向等,参数中的iMapMode为新的映射方式,其可以选择的取值如表7.1所示,Windows默认使用的映射方法为MM_TEXT。
表7.1 Windows中可用的坐标映射方式
可以看到,除了默认的MM_TEXT方式外,下面5种映射方式:MM_HIENGLISH,MM_LOENGLISH,MM_HIMETRIC,MM_LOMETRIC和MM_TWIPS采用的都是原点位于左上角、Y正方向向上的映射方式,另外,它们的坐标逻辑单位是不同的。
最后的两种映射方式MM_ISOTROPIC和MM_ANISOTROPIC提供了更灵活的选择,设置为这两种映射方式后,程序可以继续调用SetViewportOrgEx,SetViewportExtEx和SetWindowExtEx函数来自由设置坐标系的原点、逻辑单位和坐标的正方向等所有参数。在其他映射方式下的时候,不能使用这3个设置函数,这时任何对它们的调用都会被忽略。
7.2 绘制图形
有了前面的这些基础,这一节将用一个时钟的例子来演示如何进行简单的绘图,例子的源代码可以在所附光盘的Chapter07\Clock目录中找到,程序运行的结果如图7.4所示。
图 7.8 FillRect,FrameRect和InvertRect函数的运行结果
图7.4 时钟程序的运行结果
资源脚本文件Clock.rc中简单定义了一个用做图标的ico文件:
//>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>#include <c:/masm32/include/resource.h>//>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>#define ICO_MAIN 0x1000ICO_MAIN ICON "Main.ico"
源文件Clock.asm如下:
;Clock.asm-------------时钟例子:使用 GDI 函数绘画指针
;使用 nmake 或下列命令进行编译和链接:
;ml /c /coff Clock.asm
;rc Clock.rc
;Link /subsystem:windows Clock.obj Clock.res
.386
.model flat,stdcall
option casemap:none ; include 文件定义
include c:/masm32/include/windows.inc
include c:/masm32/include/user32.inc
includelib c:/masm32/lib/user32.lib
include c:/masm32/include/kernel32.inc
includelib c:/masm32/lib/kernel32.lib
include c:/masm32/include/gdi32.inc
includelib c:/masm32/lib/gdi32.lib ;equ 等值定义
ICO_MAIN equ 1000h
ID_TIMER equ 1 ;数据段
.data?
hInstance dword ?
hWinMain dword ?
dwCenterX dword ? ;圆心X
dwCenterY dword ? ;圆心Y
dwRadius dword ? ;半径
.const
szClassName byte 'Clock', 0;代码段
.code
;计算时钟的位置、大小等参数
_CalcClockParam proc local @stRect:RECT invoke GetClientRect, hWinMain, addr @stRect mov eax, @stRect.right sub eax, @stRect.left ;eax = 宽度mov ecx, @stRect.bottom sub ecx, @stRect.top ;ecx = 高度;比较客户区宽度和高度,以小的值作为时钟的直径.if ecx > eax mov edx, eax ;高度 > 宽度sub ecx, eax shr ecx, 1 mov dwCenterX, 0mov dwCenterY, ecx .else mov edx, ecxsub eax,ecx shr eax, 1mov dwCenterX, eax mov dwCenterY, 0.endif shr edx, 1 mov dwRadius, edx add dwCenterX, edx add dwCenterY, edx ret
_CalcClockParam endp ;计算时钟圆周上某个角度对应的 X 坐标
;X = 圆心X + Sin(角度) * 半径
_dwPara180 dw 180
_CalcX proc _dwDegree, _dwRadius local @dwReturn fild dwCenterX fild _dwDegree fldpi fmul ;角度*Pifild _dwPara180 fdivp st(1), st ;角度*Pi/180fsin ;Sin(角度*Pi/180)fild _dwRadius fmul ;半径*Sin(角度*Pi/180)fadd ;X+半径*Sin(角度*Pi/180)fistp @dwReturn mov eax, @dwReturn ret
_CalcX endp ;计算时钟圆周上某个角度对应的 Y 坐标
;Y = 圆心Y - Cos(角度) * 半径
_CalcY proc _dwDegree, _dwRadius local @dwReturn fild dwCenterY fild _dwDegree fldpi fmul fild _dwPara180 fdivp st(1), st fcos fild _dwRadius fmul fsubp st(1), st fistp @dwReturn mov eax, @dwReturn ret
_CalcY endp ;按照 _dwDegreeInc 的步进角度,画 _dwRadius 为半径的小圆点
_DrawDot proc _hDc, _dwDegreeInc, _dwRadius local @dwNowDegree, @dwR local @dwX, @dwY mov @dwNowDegree, 0mov eax, dwRadius sub eax, 10mov @dwR, eax .while @dwNowDegree <= 360finit ;计算小圆点的圆心坐标invoke _CalcX, @dwNowDegree, @dwR mov @dwX, eax invoke _CalcY, @dwNowDegree, @dwR mov @dwY, eax ;画点mov eax, @dwX mov ebx, eax mov ecx, @dwY mov edx, ecx sub eax, _dwRadius add ebx, _dwRadius sub ecx, _dwRadius add edx, _dwRadius invoke Ellipse, _hDc, eax, ecx, ebx, edx mov eax, _dwDegreeInc add @dwNowDegree, eax .endw ret
_DrawDot endp ;画 _dwDegree 角度的线条,半径=时钟半径-参数_dwRadiusAdjust
_DrawLine proc _hDc, _dwDegree, _dwRadiusAdjust local @dwR local @dwX1, @dwY1, @dwX2, @dwY2 mov eax, dwRadius sub eax, _dwRadiusAdjust mov @dwR, eax ;计算线条两端的坐标invoke _CalcX, _dwDegree, @dwR mov @dwX1, eax invoke _CalcY, _dwDegree, @dwR mov @dwY1, eax add _dwDegree, 180invoke _CalcX, _dwDegree, 10mov @dwX2, eax invoke _CalcY, _dwDegree, 10mov @dwY2, eax invoke MoveToEx, _hDc, @dwX1, @dwY1, NULL invoke LineTo, _hDc, @dwX2, @dwY2 ret
_DrawLine endp _ShowTime proc _hWnd, _hDClocal @stTime:SYSTEMTIME pushad invoke GetLocalTime, addr @stTime invoke _CalcClockParam ;画时钟圆周上的点invoke GetStockObject, BLACK_BRUSH invoke SelectObject, _hDC, eax invoke _DrawDot, _hDC, 360/12, 3 ;画12个大圆点invoke _DrawDot, _hDC, 360/60, 1 ;画60个小圆点;画时钟指针invoke CreatePen, PS_SOLID, 1, 0invoke SelectObject, _hDC, eax invoke DeleteObject, eax movzx eax, @stTime.wSecond mov ecx, 360/60mul ecx ;秒针度数 = 秒 * 360/60invoke _DrawLine, _hDC, eax, 15 ;----------------------------------------invoke CreatePen, PS_SOLID, 2, 0invoke SelectObject, _hDC, eax invoke DeleteObject, eax movzx eax, @stTime.wMinute mov ecx, 360/60mul ecx ;分针度数 = 分 * 360/60invoke _DrawLine, _hDC, eax, 20;-----------------------------------------invoke CreatePen, PS_SOLID, 3, 0invoke SelectObject, _hDC, eax invoke DeleteObject, eax movzx eax, @stTime.wHour .if eax >= 12 sub eax, 12 .endif mov ecx, 360/12 mul ecx movzx ecx, @stTime.wMinute shr ecx, 1 add eax, ecx invoke _DrawLine, _hDC, eax, 30;------------------------------------------invoke GetStockObject, NULL_PEN invoke SelectObject, _hDC, eax invoke DeleteObject, eax popad ret
_ShowTime endp _ProcWinMain proc uses ebx edi esi, hWnd, uMsg, wParam, lParam local @stPS:PAINTSTRUCT mov eax, uMsg .if eax == WM_TIMER invoke InvalidateRect, hWnd, NULL, TRUE .elseif eax == WM_PAINT invoke BeginPaint, hWnd, addr @stPS invoke _ShowTime, hWnd, eax invoke EndPaint, hWnd, addr @stPS .elseif eax == WM_CREATE invoke SetTimer, hWnd, ID_TIMER, 1000, NULL .elseif eax == WM_CLOSE invoke KillTimer, hWnd, ID_TIMER invoke DestroyWindow, hWinMain invoke PostQuitMessage, NULL .else invoke DefWindowProc, hWnd, uMsg, wParam, lParam ret .endif xor eax, eax ret
_ProcWinMain endp _WinMain proc local @stWndClass:WNDCLASSEX local @stMsg:MSG invoke GetModuleHandle, NULL mov hInstance, eax ;注册窗口类invoke RtlZeroMemory, addr @stWndClass, sizeof @stWndClass invoke LoadIcon, hInstance, ICO_MAIN mov @stWndClass.hIcon, eax mov @stWndClass.hIconSm, eax invoke LoadCursor, 0, IDC_ARROW mov @stWndClass.hCursor, eax push hInstance pop @stWndClass.hInstance mov @stWndClass.cbSize, sizeof WNDCLASSEX mov @stWndClass.style, CS_HREDRAW or CS_VREDRAW mov @stWndClass.lpfnWndProc, offset _ProcWinMain mov @stWndClass.hbrBackground, COLOR_WINDOW + 1 mov @stWndClass.lpszClassName, offset szClassName invoke RegisterClassEx, addr @stWndClass ;建立并显示窗口invoke CreateWindowEx, WS_EX_CLIENTEDGE, \offset szClassName, offset szClassName, \WS_OVERLAPPEDWINDOW, \100, 100, 250, 270, \NULL, NULL, hInstance, NULL mov hWinMain, eax invoke ShowWindow, hWinMain, SW_SHOWNORMAL invoke UpdateWindow, hWinMain ;消息循环.while TRUE invoke GetMessage, addr @stMsg, NULL, 0, 0.break .if eax == 0invoke TranslateMessage, addr @stMsg invoke DispatchMessage, addr @stMsg .endw ret
_WinMain endp main proc call _WinMaininvoke ExitProcess, 0
main endp
end main
下面简单分析一下程序的结构。
程序首先用标准的方法建立了一个窗口,在窗口的初始化消息WM_CREATE中用SetTimer建立了一个周期为1秒的定时器,用来在窗口的客户区中绘画时钟。这个定时器在WM_CLOSE消息中用KillTimer函数撤销。在定时器消息中,程序用InvalidateRect函数让整个客户区失效,相当于让Windows在消息循环中放入一条WM_PAINT消息,整个时钟的绘画在WM_PAINT消息中完成。
在WM_PAINT消息中程序用标准的方法调用BeginPaint函数获取窗口客户区的hDC,以便在上面绘画时钟,在消息返回的时候用EndPaint函数释放hDC,两个函数的中间,程序把hDC传给_ShowTime子程序,由这个子程序完成整个绘画工作。
在第6章中已经讲到:因为获取系统时间不能依赖于WM_TIMER消息的计数,所以在_ShowTime子程序的开始,程序调用GetLocalTime来获取当前的系统时间,并根据这个时间来绘画时钟的时、分、秒指针。由于绘画的过程很快,所以整个程序的结构使用前面图7.1中所示的A结构,也就是每次有WM_PAINT消息的时候,程序总是重画整个客户区,所以读者在速度比较慢的计算机上运行这个程序时,可能会看到有个闪烁的过程,因为程序每次总是先将整个客户区清除成背景色(InvalidateRect函数最后的TRUE参数要求Windows在发送WM_PAINT消息前清除客户区),然后绘画四周的刻度,最后画上指针。绘画刻度是由_DrawDot子程序完成的,绘画指针是由_DrawLine子程序完成的。
GetLocalTime后面的_CalcClockParam子程序根据客户区的尺寸计算时钟尺寸参数,它比较客户区高度和宽度,以其中的较小值用做时钟的直径,计算得到的圆心最后存放于全局变量dwCenterX和dwCenterY中,计算得到的半径存放于dwRadius中。
程序中有两个公用的子程序:_CalcX和_CalcY,它们用来计算角度对应的坐标,如图7.5所示,时钟0点时间是从垂直方向开始的,以时间值为角度配合Windows的默认坐标系,对应某个时间点(x,y),x应该是圆心x加上角度的正弦值乘以半径,y应该是圆心y减去角度的余弦值乘以半径。_CalcX和_CalcY输入的参数是角度_dwDegree和半径_dwRadius。子程序中使用80×86的协处理器指令,首先将角度值换算成弧度值——乘以π并除以180,然后用上面分析的公式进行浮点计算并将结果返回。
图7.5 时钟程序的坐标计算
在接下来的内容中,先介绍一些绘画操作的背景知识。
7.2.1 画笔和画刷
GDI中的绘画函数有3大类:画点、画线和画填充区域。使用过Photoshop等图形软件的读者一定知道,在画线之前需要选择一种画笔,这样画出来的线条都是基于这种画笔的;同样,填充一个区域之前需要选择一种画刷,这样整个填充区域将重复使用这个画刷的颜色或图案。
GDI中也有同样的画笔和画刷的概念,画笔、画刷,以及其他一些GDI中要使用的东西,包括字体、区域、路径、图案和位图统称GDI中的“对象”,通过SelectObject函数可以指定一个DC当前使用的对象对应哪个对象句柄,称为“当前对象”,当设置了一个当前对象的时候,以后和这种对象相关的函数都将使用当前对象,直到再次用SelectObject选择新的对象为止。比如,当选择了新的画笔后,以后所有画线函数画出来的线条样式都是由这个画笔决定的,而选择了新的画刷后,则所有填充函数填充的样式都将使用这个画刷。
SelectObject函数的用法是:
invoke SelectObject,hDC,hGDIObject
mov hOldObject,eax
其中参数hGDIObject就是对象的句柄,它可以是位图句柄、画笔句柄、画刷句柄、字体句柄或区域句柄,函数会根据句柄的种类自动替换原有的对象,并将原来使用的对象句柄返回(当对象类型是区域的时候除外),如果DC中原来没有设置当前对象,那么函数的返回值是GDI_ERROR或NULL。
1.使用预定义的画笔和画刷
Windows预定义了一些常用的画笔和画刷,在程序中可以用GetStockObject来获取它们的句柄,Stock的中文含义是“常备的、库存的”,所以这个函数字面上的意思就是“获取常用的对象”,注意并没有类似于GetStockPen或GetStockBrush之类的函数,所有获取常用对象的操作统一使用GetStockObject函数。
GetStockObject函数的用法是:
invoke GetStockObject,fnObject
mov hObject,eax
fnObject参数是预定义的对象类型,可以是表7.2所示的取值。
NULL_PEN和NULL_BRUSH是空画笔和空画刷,之所以有空的对象,是因为绘制填充区域的函数同时用到了画笔和画刷——绘制的外框使用当前画笔,中间用当前画刷填充。使用空对象可以有机会画出没有边框线只有填充图案,或者只有边框线而不填充的区域来。
表7.2 GDI中的常用对象
用GetStockObject函数得到对象句柄以后,就可以用SelectObject函数将对象句柄设置到DC中了。例子文件Clock.asm中的_ShowTime函数中用GetStockObject函数获取了一个BLACK_BRUSH画刷,用来绘画时钟的刻度。
2.使用自定义的画笔和画刷
使用GetStockObject函数得到的对象是最“简陋”的,如画笔只能是白色或黑色的宽度为1像素的实线,画刷只能是白色、黑色和有限的几种灰色色块。要想使用彩色的、多种多样风格的画笔和画刷,就必须用自定义的方法。
创建自定义的画笔可以使用CreatePen,ExtCreatePen或CreatePenIndirect函数,CreatePen函数的使用方法是:
invoke CreatePen,fnPenStyle,dwWidth,dwColor
mov hPen,eax
fnPenStyle参数是画笔风格,它可以是两种实线风格PS_SOLID,PS_INSIDEFRAME或空画笔PS_NULL,以及几种虚线风格PS_DASH,PS_DOT,PS_DASHDOT或PS_DASHDOTDOT。它们对应的线条如图7.6所示,图中从上到下分别是PS_SOLID,PS_DASH,PS_DOT,PS_DASHDOT,PS_DASHDOTDOT和PS_INSIDEFRAME风格的线条,几种虚线的风格很好记,只要记得“点”就是DOT,“划”就 是DASH就 可 以了,如PS_DASHDOTDOT风格就是由“划、点、点”重复组成的虚线。
图7.6 几种自定义画笔风格
PS_SOLID和PS_INSIDEFRAME风格的画笔使用的都是实线线条,它们之间的区别在于当画笔的宽度大于1像素并使用区域绘画函数时,PS_SOLID线条会居中画于边线上,而PS_INSIDEFRAME线条会全部画在边线里面,它的宽度会向区域的内部扩展,所以它的名称是INSIDEFRAME。
CreatePen函数的dwWidth参数定义了画笔的宽度,单位是DC坐标映射方法中定义的逻辑单位,如果这个参数使用NULL,那么函数会使用1像素的宽度。宽度参数会影响到风格参数:当宽度大于1的时候,画笔风格不能使用虚线,这时候即使指定了虚线风格,函数也会自动使用PS_SOLID风格。dwColor参数指定了画笔的颜色。
例子源代码的_ShowTime子程序中用不同宽度的线条来绘画时、分、秒指针,绘画前就使用CreatePen函数创建了不同宽度的画笔。
如果需要创建更复杂的画笔,可以使用ExtCreatePen函数。这个函数除了有CreatePen的全部功能外,还可以让用户自己定义线条的样子,这样可以不必限制于上面的点点划划了。函数的用法读者可以参考函数手册。
创建自定义画刷可以使用的函数有:CreateSolidBrush,CreateHatchBrush,CreatePatternBrush和CreateBrushIndirect。
CreateSolidBrush创建单色的画刷:
invoke CreateSolidBrush,dwColor
mov hBrush,eax
要输入的唯一参数是画刷的颜色。而CreateHatchBrush可以创建几种预定义图案的画刷:
invoke CreateHatchBrush,iHatchStyle,dwColor
mov hBrush,eax
dwColor指定了图案线条的颜色,iHatchStyle定义了不同的图案线条,这些图案线条实际上是以8×8的位图重复铺开组成的,iHatchStyle的定义值可以是HS_BDIAGONAL,HS_CROSS,HS_DIAGCROSS,HS_FDIAGONAL,HS_HORIZONTAL和HS_VERTICAL,这6种图案的花样在图7.7中从左到右排列显示。
图7.7 CreateHatchBrush中的画刷图案
如果这些简单的图案不能满足使用要求,CreatePatternBrush是个很好的选择:
invoke CreatePatternBrush,hBitmap
mov hBrush,eax
这个函数用一个位图当做画刷的图案,当要绘画的区域大于位图尺寸的时候,位图被重复铺开,就像HTML文件中的背景图案一样。读者可以尝试一下用一幅做网页文件背景的位图创建一个位图画刷,并且在RegisterClassEx时在WNDCLASSEX结构中的hbrBackground字段中使用这个画刷,这样创建出来的窗口背景会和网页背景一样华丽!演示代码请参考所附光盘的Chapter07\TestObject目录中的源代码。
对于自定义的画笔和画刷,还有其他自定义的对象,在不再需要的时候必须使用DeleteObject函数删除,但是要注意:当对象还是一个DC的当前对象的时候不要将它删除,在删除前应该确定DC中已经选入了其他的对象。与之相反,用GetStockObject获取的预定义对象使用后不需要删除,但是对它们调用DeleteObject也没有关系,因为它们不会被真正删除。由于SelectObject返回值就是DC原来使用的对象句柄,所以删除对象的一个好时机就是当SelectObject返回的时候,如例子程序的_ShowTime子程序中用的:
invoke CreatePen,PS_SOLID,2,0
invoke SelectObject,_hDC,eax
invoke DeleteObject,eax
SelectObject将CreatePen创建的画笔句柄选入DC,返回值eax就是以前使用的画笔句柄,这个句柄不再使用了,所以可以在下面用DeleteObject直接删除,而这次建立的画笔可以在下次执行SelectObject后用同样的方法删除。
7.2.2 绘制像素点
在DC上绘制像素点是绘图最基本的操作,使用的方法是:
invoke SetPixel,hDC,dwX,dwY,dwColor
SetPixel函数在hDC的dwX、dwY位置以dwColor为颜色画上一个像素点,如果需要获取hDC中某个像素点当前的颜色值,那么可以使用GetPixel函数:
invoke GetPixel,hDC,dwX,dwY
mov dwColor,eax
虽然绘画像素是最基本的绘图操作方法,但是在程序中一般很少使用SetPixel函数,因为它的开销太大了,只适合用在需要少量绘画像素的地方,如果要绘画一个线条或者整个区域,那么最好使用画线函数或者填充函数,因为这些函数是在驱动程序级别上完成的,所有的硬件加速功能都可以用上。
图形处理前最基本的步骤是获取像素,但也不应该用GetPixel函数来获取一大块的像素数据,理由是同样的。如果要分析整个区域的像素数据,最好的办法就是用GetDIBits函数将全部数据拷贝到内存中再进行处理。
7.2.3 绘制图形
GDI的图形绘制函数主要有绘制线条和填充区域两大类。绘制线条的函数以当前画笔绘制线条;绘制填充区域的函数以当前画笔绘制边线,并以当前画刷填充中间的区域。
1.绘制线条
绘制线条的函数有画直线的LineTo,画多条直线的Polyline和PolylineTo,画贝塞儿曲线的PolyBezier和PolyBezierTo,画弧线的Arc和ArcTo。
DC的数据结构中有一个“当前点”,LineTo函数就是从当前点画一条直线到参数中指定的点,并把参数中指定的点设置为新的当前点。画线函数中所有以To结尾的函数都是从当前点开始绘制的,如LineTo,PolylineTo,PolyBezierTo和ArcTo,由于这些函数在绘画结束后会把绘制的最后一点设置为新的当前点,所以在使用这些函数的时候要考虑到当前点也是参与绘制的坐标一部分。而其余的Polyline,PolyBezier和Arc函数则和当前点没有关系,也不会影响当前点的位置。
如果要设置当前点的位置,可以使用MoveToEx函数:
invoke MoveToEx,hDC,dwX,dwY,lpPoint
dwX和dwY指出了新的当前点的坐标,lpPoint指向一个空的POINT结构,用来返回原来的当前点位置,如果不需要的话,这个参数可以使用NULL。
另一个函数也可以得到当前点的坐标:
invoke GetCurrentPositionEx,hDC,lpPoint
同样,lpPoint指向一个用来返回当前点坐标的POINT结构地址。
如果要绘制一条直线,必须配合使用MoveToEx和LineTo函数,首先由MoveToEx函数设置一个当前点当做起始坐标,然后用LineTo绘画到结束坐标,如Clock.asm中的_DrawLine子程序中就是这样绘制时钟指针的:
invoke MoveToEx,_hDC,@dwX1,@dwY1,NULL
invoke LineTo,_hDC,@dwX2,@dwY2
这两句代码绘画一条从@dwX1,@dwY1到@dwX2,@dwY2的直线。
如果要绘制是相连的多条直线,可以使用Polyline或PolylineTo函数:
invoke PolylineTo,hDC,lpPoint,cPoints
invoke Polyline,hDC,lpPoint,cPoints
lpPoint指向一个包含一系列POINT结构的缓冲区,由于POINT结构只有X和Y两个字段,所以缓冲区中的数据实际上是x1,y1,x2,y2,x3,y3,…,cPoints参数指出了点的数目,注意:PolylineTo画出的直线是从当前点坐标(x,y)开始,然后到(x1,y1),再到(x2,y2),…,而Polyline函数画出的直线是从(x1,y1)开始的,对于这个函数,如果cPoints参数指定了n个点,那么直线的数量实际上是n−1。当绘制的相连直线很多的时候,用Polyline或PolylineTo比多次使用LineTo的速度要快很多,就像用填充函数比多次使用SetPixel要快一样。
表7.3举例说明了这些画线函数的功能,表中的(x1,y1)或(x2,y2)等表示点1或点2的坐标,(xc,yc)表示当前点的坐标,当前点在图中用c表示。
表7.3 画线函数的功能
对于Arc和ArcTo函数,参数(x1,y1)和(x2,y2)定义了一个矩形的对角点,然后在和这个矩形相切的椭圆上面,以椭圆的中心(也就是矩形的中心)画两条假想的直线到(x3,y3)和(x4,y4),这两条直线和椭圆相交的点就是圆弧的起始点和结束点。在默认情况下,圆弧由起始点沿着椭圆从逆时针方向画到结束点。不过绘画方向可以由SetArcDirection函数重新规定:
invoke SetArcDirection,hDC,AD_COUNTERCLOCKWISE ;逆时针方向
读者一定注意到了一个问题:在画线的时候,如果当前的画笔是虚线的话,虚线的不连续部分实际上是由白色组成的,当虚线画在非白色的背景上的时候这一点显得特别明显。实际上,可以选择这些不连续部分的颜色,用以下的语句就可以做到这一点:
invoke SetArcDirection,hDC,AD_CLOCKWISE ;顺时针方向
invoke SetBkColor, hDC, dwColor
调用后不连续的部分就将用dwColor指定的颜色绘画。
但是改变颜色也并不是唯一的选择,GDI允许这部分并不绘画任何颜色,也就是可以是“透明”的,用下面的调用可以将模式在透明和非透明之间切换:
invoke SetBkMode,hDC,OPAQUE ;非透明模式
invoke SetBkMode,hDC,TRANSPARENT ;透明模式
两种模式以及绘画颜色不单影响虚线的空隙部分,同样也影响CreateHatchBrush函数创建的画刷,因为这种画刷使用几种由线条构成的图案,当用这种画刷填充一个区域的时候,线条图案的空隙部分同样受SetBkColor函数和SetBkMode函数的影响。
2.绘制边界框和填充区域
绘制边界框和填充区域其实是同一件事情。如果当前画笔是NULL_PEN的话,画出来的是没有边线的填充区域;如果当前画刷是NULL_BRUSH的话,那么只有边线而不会填充;如果当前画刷既不是NULL_PEN也不是NULL_BRUSH,那么画出来的图形既有边线也是填充的。
绘制区域的函数有画矩形的Rectangle,画圆角矩形的RoundRect,画多边形的Polygon,画弦的Chord,画圆饼的Pie和画椭圆的Ellipse。这些函数的使用效果如图7.4所示。
表7.4 填充函数的功能
在这些函数中,Polygon的调用方式和Polyline很相似,只不过如果最后一点和第一点不同的话,函数自动再画一条和起始点相连的直线将整个区域闭合起来。用Polygon绘画的多边形中各条直线可能相交,Windows允许程序自行选择填充的模式,可以是表7.4中Polygon一栏中的上面那个图例(填充全部区域),也可以是下面那个图例(间隔填充区域)。可以用下面的函数切换填充的模式:
invoke SetPolyFillMode,_hDC,ALTERNATE ;间隔填充
invoke SetPolyFillMode,_hDC,WINDING ;填充全部区域
Chord函数和Pie函数的参数使用和画弧线的Arc函数相似,只不过Chord函数将弧线的两端直接相连,形成一个“弦”,而Pie函数将两端和圆心相连,形成一个“圆饼”,这两个函数绘画的方向同样受SetArcDirection函数设置的影响。
在例子Clock.asm中,程序在_DrawDot子程序中用Ellipse函数绘画时钟的刻度,读者也可以将程序改动一下,尝试着用Polygon画五角星来当做时钟的刻度。
除了这些函数,还有3个和矩形有关的填充函数:FillRect,FrameRect和InvertRect,这些函数不使用当前画笔画边线,也不用当前画刷填充,其中FillRect函数用指定的画刷hBrush填充一个lpRect指定的矩形区域,lpRect指向一个RECT结构;FrameRect函数用指定画刷hBrush绘画边线;InvertRect函数将lpRect指定的矩形区域中的颜色值取反。用法如下:
invoke FillRect,hDC,lpRect,hBrush
invoke FrameRect,hDC,lpRect,hBrush
invoke InvertRect,hDC,lpRect
假设背景为白色,而参数中hBrush指定的画刷为灰色画刷,那么上述3个函数的运行结果如图7.8所示。
图中左边是FillRect的运行结果,可以看到图案没有边线;中间是FrameRect的运行结果,它用灰色画刷绘画边线,得到了一个灰色的矩形边框;右边是InvertRect的运行结果,由于底色是白色的,白色取反得到的是黑色,所以整个矩形都变成了黑色。
7.2.4 绘图模式
在前面的内容中我们都是尝试在DC上用绘图函数画出需要的图形,对于DC上被绘画上去的像素来说,相当于用画笔(或画刷)的像素点代替了原来的像素点,但Windows也可以用画笔的像素点和原来的像素点进行计算以后的值当做新的像素点,这个计算的过程就叫做光栅运算,光栅运算的方法用“光栅运算符”来定义——英文缩写是ROP(Raster Operation),ROP码是一些取反、异或、拷贝、或及与等位运算方法的组合。
对于绘图函数,Windows定义了16种ROP码,如表7.5所示。表中的“像素”指DC中要绘画位置原来的像素值,画笔指要画上去的颜色值,当然ROP码影响的并不单是画笔画出的线条,同样影响用画刷填充的区域,所以读者不要被表中的“PEN”搞混淆了,这个“PEN”指的是“Pen and Brush”!
ROP为一些应用提供了方便,比如需要在背景上拖动一个图形,如果用普通的绘画方法,那么在绘画前必须保存原来背景的数据,在图形拖动后再恢复,然后在新的位置再保存、再绘画,如此重复。但如果使用R2_XORPEN或R2_NOTXORPEN的绘画模式,因为xor操作两遍就是原来的数值,所以无须保存原来的像素,在相同的地方再绘画一遍就相当于恢复原来的图形。而用R2_BLACK和R2_WHITE就相当于不管画笔和画刷是什么颜色,画出来的全部是黑色或白色。
表7.5 绘图模式中可以使用的ROP码
对于一个DC来说,默认的绘图模式是R2_COPYPEN,就是用画笔或画刷的颜色替换掉原来像素的颜色。如果要设置新的绘图模式,可以使用SetROP2函数。如下面的语句将绘图模式设置为R2_NOTCOPYPEN模式,这样以后的所有的绘图函数就将以画笔或画刷取反后的颜色绘图了:
invoke SetROP2,hDC,R2_NOTCOPYPEN
如果要获取当前的绘图模式,可以使用GetROP2函数,函数返回当前的模式:
invoke GetROP2,hDC