第4章 入门心法——Windows游戏图形基础(上)

4.1 Windows 图形设备接口(GDI)

4.1.1 GDI 的初印象

GDI 是Windows 操作系统的“ 三大长老” 之一。如果缺少它,Windows 操作系统不可能有如今这么美观漂亮的界面。
GDI ,即图形设备接口,英文全称为Graphics Device Interface 或Graphical Device Interface,缩写为GDI 。
GDI 是微软公司设计的一套API ,是风靡全球的Windows 操作系统的三大核心部件( 也称“子系统”〉之一。GDI 在Windows 操作系统中的地位非常地高,它掌管了所有显像设备的图像显示及输出功能, 如果缺少它, Windows 操作系统就不可能有如今这么美观漂亮的图形操作界面了。
GDI 是Windows 图形显示程序与实际物理设备之间的桥梁,GDI 使得用户无需关心具体设备的细节,而只需在一个虚拟的环境(即逻辑设备〉中进行操作。
GDI 为应用程序提供了图形设备无关的接口,包括视频显示,打印机,画图仪和传真机等。

4.1.2 用GDI 写游戏的认知

现今都是用专业的图形API 来进行商业游戏开发的大环境下, 直接用GDI 来写游戏非常不推荐。在游戏业界中, GDI 是以绘图效率低出名的。所谓的绘图效率低, 是与专业的图形API 如DirectX和OpenGL 相比而言的。但是, 不可否认的是, GDI 用起来简单且无处不在, 很多时候专业图形API 的底层还需要借助GDI 来实现很多功能。
且暂时不去考虑游戏引擎, 因为游戏引擎实际上就是对图形库DirectX 以及OpenGL 的再封装而己。所以说, 我们很难说在完全不了解GDI 的情况下,就能直接利用专业的图形API (如DirectX)来开发出自己心仪的游戏作品。
举个例子, DirectX 中的2D文字输出函数DrawText,其实就是对GDI 中TextOut 函数的再封装。
直接用GDI 进行游戏开发并不是不可以,在PC 游戏早期, 直接用GDI 开发游戏还是很常见的。但我们如今的这个对游戏画面的要求与内容越来越高的时代已经大不相同, GDI 已经完全不能胜任当今的游戏的开发了。

4.1.3 关于GDI+

GDI +看起来虽然很美好, 但是它比GDI 更不适合用于写游戏。
我们在学习GDI 的同时,还会接触到GDI+ 。GDI+可以理解为GDI 的一个技术升级版。在很多方面都添加了GDI 不曾有过的功能,很多人认为, GDI+最出彩的一点是对多图片格式的支持,如现在常用的png 、jpg 格式, 都有着很好的支持。这都是GDI 在格式支持方面就显得很寒瞎了, GDI 只对常见的bmp 位图格式支持得很好。
但是我们需要注意一点, GDI+由于是在GDI 的基础上再次开发的,比GDI 更加高层, 也就是说离底层硬件更远,所以执行起来需要“跑更远的距离” ,所以绘图效率却远不如GDI 。GDI +说白了其实就是在GDI 的基础上进行的再封装,用功能换性能。

4.1.4 GDI 的特点

从一种宏观的角度来看,GDI无非是把绘图相关的几百个函数和一些相关的数据结构和宏糅合在一起而已。
而对GDI 而言,它的使命就是实现一套通用的图形对象,来向屏幕、内存甚至是打印机等设备进行绘图操作。
概括起来, GDI 具有如下的特点:
• 不允许程序直接访问物理显示硬件,通过称为“设备环境” 的抽象接口间接访问显示硬件;
• 程序需要与显示硬件(显示器、打印机等)进行通讯时,必须首先获得与特定窗口相关联的设备环境;
• 用户无需关心具体的物理设备类型;
• Windows 参考设备环境的数据结构完成数据的输出。

4.1.5 GDI 中的基本图形

1 . 直线和曲线
线条是所有向量图形绘制系统的基础。GDI 支持直线、矩形、椭圆。
2 . 填入区域
当一系列直线或者曲线封闭了一个区域时,这个区域可以使用目前的GDI 画刷对象进行填图。
3 . 位图
位图其实是位的矩形数组,这些位对应于显示设备上的像素, 它们是位映像图形的基础工具。

4 . 文字
文字通常不仅是所有的计算机图形系统中最复杂的部分,而且(如果识字还是社会基本要求的话) 也是最重要的部分。用于定义GDI 字体对象和取得字体信息的数据结构是Windows 中最庞大的部分之一。

4.1.6 GDI 的函数分类

上面我们已经提到过,如果从一种宏观的角度来看的话, GDI 无非是由几百个函数和一些相关的数据结构与宏所组成的整体而己。函数自然是GDI 中最重要的组成部分,我们来简单看一看他们的分类:
• 取得(或者建立)和释放(或者清除)设备上下文的函数
• 取得有关设备上下文信息的函数
• 绘图函数
• 设定和取得设备上下文参数的函数
• 使用GDI 对象的函数
上面的这些函数分类的类别中都不约而同地涉及了一个概念一一设备上下文(或者称设备环境、设备DC 、DC ,这个家伙的绰号多着呢〉。在下一节中,我们就来看看这个贯穿着GDI 编程始终的,绰号一大把的家伙。

4.2 设备环境(DC)

4.2.1 设备环境的基本概念

DC ,英文全称为Device Context ,我们一般译为设备环境,或者设备上下文,亦或是设备描述表。它是GDI 程序设计中最基本也是最重要的概念。就绘图的观点来言, DC 就是程序可以进行绘图的地方。
举个例子的话,如果要在整个屏幕区上绘图,那么Device (设备)就是屏幕。而DC 就是屏幕区上的绘图层。
如果要在窗口中绘图,那么设备( Device )就是窗口,设备环境( DC )就是窗口上可以绘图的地方,也就是之前我们说过的“客户区” 。
系统中可以具有多个设备环境,每一个设备环境都有一个与之对应的关联设备。
应用程序在进行图像输出时,我们只需关心设备环境的类型。如果需要将图像输出到特定的设备,只需要创建相应类型的设备句柄(HDC )就可以了,而对于不同类型的设备环境的操作方式都是统一的。
当我们的程序需要用GDI 来绘图时,必须先取得设备环境的句柄。在取得了句柄后,Windows 用内定的属性值填入到内部设备的内容结构中。当程序在显示区域绘图完毕后,我们必须释放掉设备环境句柄。句柄我们之前已经讲到过,它其实是一个数值而已。句柄被程序释放后就不再有效了,而且不能再使用。
另外有一点需要注意,程序必须在处理单个消息处理期间取得和释放句柄。

4.2.2 获取设备环境句柄( HDC ) 的两种方式

想要用GDI 绘图首先要抓住设备环境的把柄(即获取设备环境的句柄,hdc ),这一小节我们给大家介绍了两套获取设备环境句柄的方案。
1 . 第一种方法
这种方法的使用有一定的局限性,需要在窗口过程函数处理WM PAINT 消息的那个case 之后使用。使用这套方法获取设备环境句柄涉及到了BeginPaint 和EndPaint 这两个函数,他们是一对好基友,总是成对出现。下面我们在MSDN 中查看这两个函数的原型并分别进行详细讲解。首先是BeginPaint 函数, 它为指定的窗口进行绘图工作做准备,并用将和绘图有关的信息填充到一个
PAINTSTRUCT 结构中:
HDC BeginPaint(
  __in   HWND hwnd,
  __out  LPPAINTSTRUCT lpPaint
);
既然一个是BeginPaint,与之对应的当然就是EndPaint 了,这个EndPaint 函数只要调用了,就表示指定窗口的绘画过程结束。而与绘图相关的具体代码,就在BeginPaint函数与EndPaint 函数之间写出来。MSDN 中是这样记载EndPaint 的原型的:
BOOL EndPaint(
  __in  HWND hWnd,
  __in  const PAINTSTRUCT *lpPaint
);

其实BeginPaint 和EndPaint 这一对朋友不仅是形影不离,一般填充它们的两个参数也是一模一样。
下面我们看一个调用实例, 首先我们定义一个全局的设备环境句柄,然后在贴出窗口过程函数中处理WM_PAINT 函数的片段:
	HDC g_hdc ; //全局设备环境句柄
	……
	case WM_PAINT:						// 若是客户区重绘消息
		g_hdc = BeginPaint( hwnd, &paintStruct );  //指定窗口进行绘图工作的准备,并用将和绘图有关的信息填充到paintStruct结构体中。
		Game_Paint( hwnd);
		EndPaint( hwnd, &paintStruct );			//EndPaint函数标记指定窗口的绘画过程结束
		ValidateRect(hwnd, NULL);		// 更新客户区的显示
		break;									//跳出该switch语句
上面这段代码是我们后面讲解GDI 游戏编程的示例程序中经常用到的代码,其中BeginPaint与EndPaint 函数之间调用的GamePaint 函数是我们自定义的一个函数,在里面我们封装了一些GDI 绘图相关的操作,这里我们把他理解为一些绘图操作就可以了。然后后面的ValidateRect 函数,是我们经常会遇到的函数, 它用于更新指定窗口的无效矩形区域,使之有效。我们既然现在遇到了,
就在这里顺便讲解一下。
BOOL ValidateRect(
  __in  HWND hWnd,
  __in  const RECT *lpRect
);
  • 第一个参数, HWND 类型的hW时, 标识一个想要修改状态的窗口。若该参数为NULL.系统将更新所有的窗口,然后在函数返回前发送WM_ERASEBKGND 和WM_NCPAINT 消息给窗口过程处理函数。
  • ·第二个参数, const_RECT 类型的*lpRect,指向一个包含需要生效的矩形的更新区域坐标的RECT 结构体,如果该参数为NULL , 所有的客户区域将会生效。
2 . 第二种方法
要得到窗口显示区域的设备内容句柄,我们还可以通过调用GetDC 函数来取得句柄。且在调用完GetDC 之后,需要调用ReleaseDC 对设备环境进行释放。GetDC 函数的原型很简单,就一个参数,是这样的:
HDC GetDC(
  __in  HWND hWnd
);
唯一的参数为HWND类型的hWnd,也就是我们的窗口句柄。在GetDC 函数中填上我们想要获取的窗口的句柄,GetDC就会根据我们提供的窗口句柄,来返回对应窗口的设备上下文DC 了。
使用GetDC 必须牢记一个细节。在用GetDC 函数获取了窗口的DC 之后,窗口的DC 就处于被占用状态, 使用完成之后必须及时将设备环境释放掉,不然其他的应用程序就无法使用了。
所以我们的GetDC 也就有了一个好基友,那就是用于释放设备上下文的ReleaseDC 。MSDN中ReleaseDC原型如下:
int ReleaseDC(
  __in  HWND hWnd,
  __in  HDC hDC
);
与BeginPaint 和EndPaint 一样,GetDC 和ReleaseDC 函数也是一对形影不离的好基友,必须成对地使用。如果在处理某消息时调用了GetDC,则必须在退出窗口消息处理程序之前调用一下ReleaseDC 。对于我们之后写的程序,处理的消息基本上都是
WM_PAINT 重绘消息。
为了便于大家理解,我们来看一个调用实例:
 HDC g_hdc;
 g_hdc = GetDC(hWnd); //获取窗口设备环境到g_hdc 中
/*会在这里以g_hdc 为媒介进行绘制*/
 ……
/*绘制完成,程序准备退出了*/
 ReleaseDC(hWnd , g_hdc ); //调用Release DC 函数释放掠设备环境

4.3 Windows 屏幕区域相关概念阐述

4:3.1 屏幕区、窗口区与客户区

任何一个游戏程序,无论是使用全屏显示模式还是窗口模式,都要建立一个窗口。当窗口建立之后,对于程序而言,显示屏幕便划分成了三个区域,他们分别是:
• 屏幕区( Screen)
• 窗口区( Window)
• 客户区( Client)
比如我们在Windows 7 操作系统以窗口模式运行了暴雪游戏公司的经典之作《魔兽争霸3 : 冰封王座》,那么,这3 个区域在屏幕中的位置如下图所示。


屏幕区域的大小是根据用户显示屏当前所使用的分辨率而定的。而且在程序中我们通常以像素来作为基本的长度度量单位.
需要注意,我们要把客户区和窗口区这两个易混淆的概念区分开。窗口区可以理解为在客户区周围包了一层窗口的边框,这些窗口边框包围着客户区, 共同组成了窗口区。

4.3.2 坐标点与坐标变换

接下来我们看一下屏幕上的二维坐标表示法。下图中是一般情况下的Windows 桌面。

可以看到是以屏幕左上角为坐标原点,然X 轴向右为正, Y 轴向下为正。
对于客户区, 坐标表示法如下图。


另外,在Windows 操作系统中,我们以屏幕左上角为原点。屏幕上的任何位置都可以用一个坐标来表示,我们称它为屏幕坐标, 当程序中调用某些以坐标点为实参的函数时,都需要给它们传递相应的屏幕坐标。而如果我们只知道该点在客户区中的位置,则需要将它转换为屏幕坐标。转换客户区坐标为屏幕坐标的方法是调用ClientToScreen()函数。而反过来,如果我们想把屏幕中的坐标转换为客户区坐标,则调用ScreenToClient()函数。

4.4 写一个GDI 程序通用框架

4.5 GDI 基本几何绘图

4.5.1 创建画笔

首先我们需要知道什么是HPENN。HPEN 是画笔对象的句柄数据类型,用于标识一个画笔对象。
新建一个画笔对象,顾名思义,我们可以使用CreatePen 函数。
HPEN CreatePen(
  __in  int fnPenStyle,
  __in  int nWidth,
  __in  COLORREF crColor
);

举一个实例吧,比如,我们调用CreatePen 创建一个宽度为20 的蓝色的实线。
比如我们调用CreatePen 创建一个宽度为20 的蓝色的实线画笔,代码就可以这样写:

COLORREF Colorblue=RGB (0, 0, 255 );
HPEN Bluepen=CreatePen (PS SOLID, 20 , Colorblue) ;

4.5.2 创建画刷

如同HPEN是画笔对象的句柄数据类型一样, HBRUSH 是画刷对象句柄数据类型,用于标识一个画刷对象。想要新建画刷的话,我们常常使用这两个API 函数: CreateSolidBrush 和CreateHatchBrush。 
下面我们来分别介绍,首先,我们来看看CreateSolidBrush 函数,它的功能是创建实心画刷, 当用于填充时,填充使用纯色。
HBRUSH CreateSolidBrush(
  __in  COLORREF crColor
);
CreateHatchBrush 用于创建一个阴影画刷,阴影画刷顾名思义,不是实心的。当用于填充时,填充的内容就是阴影线。
  • 第一个参数,int 类型的fnStyle ,用于指定刷子的阴影样式。
  • 第二个参数, COLORREF 类型的clrref,指定用于我们阴影画刷的前景色,填一个RGB颜色值就好了。
另外还有一个创建画刷的函数, CreatePatternBrush 。它的功能则是创建一个格式画刷,在使用格式画刷进行填充时,所填充的内容是位图。不过我们不常用,大家知道有这个函数就可以了,说不定哪个时候还可以用到。

4.5.3 图形对象的选择

在定义好画笔和画刷后,想使用它们的话,就必须在需要使用它们绘图的设备上下文中选用它们。就像我们用不同颜色的笔可以画出不同颜色的画一样。我们之前创建画笔和画刷的过程可以理解为在造笔。有笔之后,我们想要绘制,就要拿起对应的笔,才能画出对应的画。这个拿起笔的过程,就是下面我们要介绍的SelectObject 函数。这个函数非常地重要,后面我们会经常用到它,无
论是画笔画刷的选择,还是字体位图等等的选择。首先,依然是在MSDN 中查看它的原型:
HGDIOBJ SelectObject(
  __in  HDC hdc,
  __in  HGDIOBJ hgdiobj
);
需要注意的是, 一个设备环境中同一时刻只能有一个画笔对象(即就算有多个画笔对象, 当前被选中的只能有一个),也只能有一个画刷对象(即就算有多个画刷对象,当前被选中的只能有一个),以此类推。所以我们在设置当前对象后,同类的原有对象会被替换掉。如果我们目前使用了一次SelectObject , 而且第二个参数hgdiobj 是一个画刷,那么原来的设备环境( DC )中的画刷就
被替换。而且这次我们调用的SelectObject 函数的返回值就是被替换的原图形对象的句柄。
另外还需注意一点, GDI 对象一经创建便会占用部分内存, 一旦我们用不到它了,务必要将它们删除掉,用于删除的函数为DeleteObject 。
BOOL DeleteObject(
  __in  HGDIOBJ hObject
);
讲了这么多了,我们总结一下。画笔及画刷的使用相当于GDI 对象的使用过程的缩影,
它也是三步曲, 六个字: 创建→选用→删除。

4.5.4 绘制图形和线条

创建完画笔和画刷后,我们下面就可以进行绘制了。绘制线条我们通常使用LineTo 与MoveToEx 函数。首先我们来看看LineTo 函数的原型:
BOOL LineTo(
  __in  HDC hdc,
  __in  int nXEnd,
  __in  int nYEnd
);
需要注意的是, LineTo 函数并不能指定线的起点,而是从画笔对象的“当前点”开始画,画完后当前点就变为了绘制的终点。当前点初始位置为(0 , 0 )。
与LineTo 相配合,我们可以使用MoveToEx 函数来移动画笔的当前点。
BOOL MoveToEx(
  __in   HDC hdc,
  __in   int X,
  __in   int Y,
  __out  LPPOINT lpPoint
);

另外, 我们再提一个绘制矩形使用的GDI 函数——Rectangle()。
 BOOL Rectangle (
   __in HDC hdc, // 设备环境句柄
   __in int nLeftRect ,  //矩形在上角X坐标
   __in int nTopRect , //矩彤在上角Y坐标
   __in int nRightRect ,  //矩形右下角X坐标
   __in int nBottornRect  //矩形右下角Y坐标
 ) ;
从上面的函数解释我们可以看到, Rectangle 函数的参数指定了所绘制的矩形的位置和长宽。
Rectangle 函数所绘制的矩形使用DC 的当前画刷进行填充,而且Rectangle 所绘制的矩形是带边框的,边框线条的样式是当前画笔的线条样式。

4.6 游戏随机数系统初步

4.6.1 游戏中的随机系统概述

每一款游戏,或大或小,都是由一段段默默无闻的算法在支撑着他们的运作,我们不能只欣赏绚丽的游戏成品表现在我们面前的华丽与光鲜,还要看到那些支撑在华丽与光鲜背后的,鲜为人知的算法。
我们知道,在游戏领域里,围绕随机性与随机数展开的一系列技术有着非常广阔的运用空间。

4.6.2 随机系统初步

在开始展开讲之前,我们必须牢记一个概念,计算机中一般不能产生绝对随机的随机数。计算机产生随机数的过程,是根据一个数(我们可以称它为种子〉为基准以某个递推公式推算出来的一系列数,当这系列数很大的时候, 就符合正态公布,从而相当于产生了随机数,但这不是真正的随机数, 当计算机正常开机后,这个种子的值是确定的,除非我们调用了改变种子数值的函数(如下
面将讲到的srand 函数),或者对系统进行了某种修改。
也就是说,计算机一般情况下只能生成相对的随机数,即伪随机数。
在很多时候,我们会使用rand() 函数与srand()配合来达到产生随机数的效果, srand 初始化随机种子, rand 产生随机数,下面进行展开的分析( 当然我们在这里先不考虑某些游戏引擎会另外设计自己的随机数产生机制。)
我们先来看一下他的原型声明:
int rand( void );

可以看到rand 函数调用起来不用设置参数,直接写就可以了:
下面我们来讲与rand 函数相互配合的srand ()函数。Srand()函数用于设置供rand()使用的随机数种子。如果我们在第一次调用rand()之前没有调用srand()的话,那么系统会为我们自动调用一次srand() 。而使用相同的数调用srand()会导致生成相同的随机数序列,这样就违背了随机的原则了。
所以,如果想得到高质量的随机数,在使用rand()函数之前, 一定要正确地调用srand()函数进行随机数种子的播种。
在MSDN 中我们查到srand 函数有以下原型:
void srand(
   unsigned int seed 
);

参数seed 必须为无符号整型,通常可以利用返回系统当前时间的time(NULL) 的返回值来当做种子( seed )。
如果每次seed 都设相同值, rand()所产生的随机数值每次就会一样。所以,为了使参数的随机数能更加地随机,我们一般都会这样来调用srand 函数:
    srand ( (unsigned) time (NULL) ) ;  //用系统时间初始化随机种子
或者这样调用也可以:
    srand (timeGetTime ());  //用系统时间初始化随机种子
在这里做一下总结,想使用随机数生成二人组rand()与srand()来生成随机数,其实非常简单:
  • 首先调用一次srand 函数初始化随机数种子。代码方面就这样写:srand ( (unsigned) time (NULL ) ) ;
  • 调用rand()函数生成随机数。
另外需要注意,如果使用了srand((unsigned)time(NULL))这句代码, 需要包含头文件time.h,因为在这句代码中包含了返回系统时间的time() 函数,它在Windows h 中并没有声明, 而是在time.h中声明的,所以需要include 一下;
而如果使用srand(timeGetTime());就不用额外包含其他头文件了,因为这个timeGetTime 在Windows.h 有声明。

4.6.3 几种随机数的简单算法

( 1 )产生一个范围内的随机数。
一般地, 我们可用j = 1+(int)(n*rand()/(RAND_MAX+ 1.0))来生成一个0 到n 之间的随机数。
若用int x = rand() % 101 ;来生成0 到100 之间的随机数这种方法并不是最好,比较好的做法是:
j=(int) (100.0 *rand()/(RAND_MAX+1.0))

(2 )筛选型随机数,如希望取0-99 的随机数,但不能是60
解决方法:
x = random(100) ;
while (x == 6) {
    x = random(100) ;
}
又如希望取0~99 的随机数,但不要5 的倍数。解决方法:
x = random(100);
while ((x % 5)==0)
 {
     x = random(100);
}

(3 )从连续的一段范围内取随机数。
如从40~50 的范围内取随机数。解决方法:
 x=random(11)+40;

( 4 )从一组乱数中取随机数。
如: 从67, 87, 34, 78, 12, 5, 9, 108, 999, 378 十个数中随机取数。解决方法: 可以用数组将些十个数存贮,然后把0~9 中取出的随机数作为序号,实现随机取数。
a= new Array(67, 87 , 34, 78 , 12 , 5 , 9, 108, 999 , 378) ;
j = random (10);
x = a[j];

4.6.4 产生一定范围内随机数的通用算法公式

这里我们列出一些实用的产生一定范围内随机数的通用算法公式,需要的时候可以进行查阅。
  •  要取得[a,b]的随机整数,使用(rand()% (b-a))+ a (结果值含a 不含b )。
  •  要取得[a,b]的随机整数,使用(rand() % (b-a+ 1))+ a (结果值含a 和b )。
  • 要取得(a,b]的随机整数,使用(rand()% (b-a))+ a 十1 (结果值不含a 含b ) 。
  •  即(通用公式:a+ rand() % n ;取得[a ,a+n)的随机整数, 其中的a是起始值,n是整数的范围) 。
  • 要取得[a, b)的随机整数,另一种表示: a + (int)(b-a) * rand()/(RAND_MAX + 1). 
  •  要取得[a,b]的随机整数, 另一种表示: a+ (int)(b-a) * rand() / (RAND_MAX ) 。
  • 要取得[0, 1 ]之间的浮点数,可以使用rand() / double(RAND_MAX) 。

4.6.5 总结

需要记住以下两个要点:
  •  计算机的伪随机数是由随机种子根据一定的计算方法计算出来的数值。所以,只要计算方法一定,随机种子一定,那么产生的随机数就是固定的。
  • 只要用户或第三方不设置随机种子,那么在默认情况下随机种子值为1 ,来自系统时钟。
然后落实到实用上面,就是使用随机数生成“ 二人组” rand () 与srand ()来生成随机数,分两步走:
1:首先调用一次srand 函数初始化随机数种子。代码方面就这样写:
srand ( (unsigned) time (NULL) ) ;
2:调用rand()函数得到随机数。



  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值