第三部分:核心游戏编程
第11章 算法、数据结构、内存管理和多线程
第12章 人工智能
第13章 游戏物理
第14章 文字时代
第15章 综合运用:编写游戏!
第11章 算法、数据结构、内存管理和多线程
"You think I can get a hug after this?"
—Bear, Armageddon(电影《世界末日》)
本章将讨论在其他游戏编程参考书常常会中疏漏的细节问题。我们将涉及编写可保存进度的游戏、演示的制作、优化理论等所有内容。本章将帮您掌握这些必需的编程细节。这样,当我们在下一章讨论人工智能的时候,你已很好地掌握了一些游戏编程的一般概念,甚至连3D运算都不再能难倒你!
本章主要内容如下:
? 数据结构
? 算法分析
? 优化理论
? 数学运算技巧
? 混合语言编程
? 游戏的保存
? 多人游戏的实现
? 多线程编程技术
数据结构
“游戏所应当采用哪种数据结构?”,这几乎是我最常被问到的问题。答案是:最快速最有效率的数据结构。然而,在大多数情况下,你并不需要采用那些所谓最先进也最复杂的数据结构。正相反,你应该尽可能将其简化。在速度比内存更重要的今天,我们宁可先牺牲内存!
记住这一点,我们先来看几个最常用于游戏的数据结构,并给你何时以及如何使用这些数据结构的建议。
静态结构和数组
数据结构最基本的形式,当然就是一个数据项单独出现的形式,如一个结构或类。如下所示:
typedef struct PLAYER_TYP // tag for forward references
{
int state; // state of player
int x,y; // position of player
// ...
} PLAYER, *PLAYER_PTR;
C++
在C++中,你不必使用typedef来定义一个结构类型;当你使用到关键字struct时,编译器会自动为之创建一个类型。此外,C++的struct甚至可以包含方法、公有和私有部分。
PLAYER player_1, player_2; // create a couple players
在这个例子中,一个带有两个静态定义的记录的数据结构就解决问题了。另一方面,如果游戏玩家多于三个,那么较好的做法是采用如下所示的数组:
PLAYER players[MAX_PLAYERS]; // the players of the game
这样,你便可以用一个简单的循环来处理所有的游戏玩家了。Okay,非常好,但是如果在游戏运行以前你不知道会有多少玩家或记录参数,那又该如何呢?
当出现这种情况时,我们应计算出数组所可能具有的元素个数的最大值。如果数目较小,如256或更小,并且每个数组元素也相当的小(少于256字节),我通常会采用静态分配内存,并使用一个计数器来计算某个时刻已激活的元素的数目。
你也许觉得这对于内存而言是一种浪费。但是它比遍历一个链表或动态结构要容易和快速得多。关键在于,如果你在游戏运行前知道数组元素的数目,并且数目不是太大,那么就在游戏启动时通过调用函数malloc()或new()来静态地预先分配内存。
警告
不要沉迷于静态数组!例如,假如你有一个大小为4KB的结构,而且可能有1到256个该结构类型的数组。为防止某些时候数组元素的个数达到256而产生溢出错误,采用静态分配内存方法时必须为之分配1MB的内存。这时,你显然需要更好的方法——采用链表或动态分配的数组来分配内存,以避免浪费。
链表
对于那些在程序启动或编译时可以预估的、简单的数据结构,数组是最合适的处理方法。但对于那些在运行时可能增大或缩小的数据结构而言,应当使用链表(linked list)这类形式的数据结构处理方法。图11-1表示了一个标准的、抽象的链表结构。一个链表由许多节点构成。每个节点都包含信息和指向表中下一个节点的指针。
图11-1:一条链表
链表用起来很酷,因为你可以将一个节点插入到链表的任意位置,同样也可以删除任意位置的节点。图11-2示意了节点插入链表的情形。由于在运行时可以插入或删除带有信息的节点,使得作为游戏程序的数据结构,链表很具吸引力。
图11-2:往链表中插入节点
链表惟一的缺点是你必须一个接一个地遍历节点来寻找你的目标节点(除非创建第二个数据结构来帮助查询)。例如,假定你要定位一个数组中的第15个元素,你只需这样便可以访问它:
players[15]
但对于链表,你需要一个遍历算法以访问链表的节点来定位目标节点。这意味着在最坏的情况下,查询的重复次数与链表的长度相等。这就是O(n)。O记号说明在有n个元素的情况下要进行与n同阶次数的操作。当然,我们可以采用优化算法和附加的包含排序索引表的数据结构,来达到与访问数组几乎同样快的速度。
创建链表
现在来看一看如何创建一个简单的链表、增加一个节点、删除一个节点,以及搜索带有给定关键字的数据项。下面是一个基本节点的定义:
typedef struct NODE_TYP
{
int id; // id number of this object
int age; // age of person
char name[32]; // name of person
NODE_TYP *next; // this is the link to the next node
// more fields go here
} NODE, *NODE_PTR;
为了访问一个链表,需要一个head指针和一个tail指针分别指向链表的头节点和尾节点。开始时链表是空的,因而头尾指针均指向NULL:
NODE_PTR head = NULL,
tail = NULL;
注意
Some programmers like to start off a linked list with a dummy node that's always empty. This is mostly a choice of taste. However, this changes some of the initial conditions of the creation, insertion, and deletion algorithms, so you might want to try it.
有些程序员喜欢以一个总是为空的哑元节点(即不表示实际数据的节点)作为一个链表的开始节点。这通常是个人习惯问题。但这会影响链表节点创建、插入和删除的算法中的初始条件,你不妨试一试。
遍历链表
出人意料的是,遍历链表是所有链表操作中最容易实现的。
1. 从head指针处开始。
2. 访问节点。
3. 链接到下一节点。
4. 如果指针非NULL,则重复第2、3步。
下面是源代码:
void Traverse_List(NODE_PTR head)
{
// this function traverses the linked list and prints out
// each node
// test if head is null
if (head==NULL)
{
printf("/nLinked List is empty!");
return;
} // end if
// traverse while nodes
while (head!=NULL)
{
// visit the node, print it out, or whatever...
printf("/nNode Data: id=%d", head->id);
printf("/nage=%d,head->age);
printf("/nname=%s/n",head->name);
// advance to next node (simple!)
head = head->next;
} // end while
print("/n");
} // end Traverse_List
很酷是不是?下一步,让我们看一看如何在链表中插入一个节点。
插入节点
插入节点的第一步是创建该节点。创建节点有两种方法:你可以将新的数据元素传递给插入函数,由该函数来构造一个新节点;或者先构造一个新节点,然后将它传递给插入函数。这两种方法在本质上是相同的。
此外,还有许多方法可以实现节点插入链表的操作。蛮横的做法是将要插入的节点插在链表的开头或结尾。如果你不关心链表中节点的顺序,这倒不失为一个便捷的方法。但如果想保持链表原来的排序,你就应当采用更聪明的插入算法,这样可以保证插入节点后的链表仍然保持升序或降序的顺序。这也可以让以后进行搜索时速度更快。
为简明起见,我举一个最简单的节点插入方法的例子,也就是将节点插在链表的末尾。其实按顺序的节点插入算法并不太复杂。首先要扫描整个链表,找出新节点所要插入的位置,然后将其插入。惟一的问题就是保证不要丢失任何指针和信息。
下面的源代码将一个新节点插入链表的尾部(比插入链表头部难度稍大)。注意一下特殊的情况,即空链表和只有一个元素的链表:
// access the global head and tail to make code easier
// in real life, you might want to use ** pointers and
// modify head and tail in the function ???
NODE_PTR Insert_Node(int id, int age, char *name)
{
// this function inserts a node at the end of the list
NODE_PTR new_node = NULL;
// step 1: create the new node
new_node = (NODE_PTR)malloc(sizeof(NODE)); // in C++ use new operator
// fill in fields
new_node->id = id;
new_node->age = age;
strcpy(new_node->name,name); // memory must be copied!
new_node->next = NULL; // good practice
// step 2: what is the current state of the linked list?
if (head==NULL) // case 1
{
// empty list, simplest case
head = tail = new_node;
// return new node
return(new_node);
} // end if
else
if ((head != NULL) && (head==tail)) // case 2
{
// there is exactly one element, just a little
// finesse...
head->next = new_node;
tail = new_node;
// return new node
return(new_node);
} // end if
else // case 3
{
// there are 2 or more elements in list
// simply move to end of the list and add
// the new node
tail->next = new_node;
tail = new_node;
// return the new node
return(new_node);
} // end else
} // end Insert_Node
As you can see, the code is rather simple. But it's easy to mess up because you're dealing with pointers, so be careful! Also, the astute programmer will very quickly realize that with a little thought, cases two and three can be combined, but the code here is easier to follow. Now let's remove a node.
你可能觉得代码比较简单。但实际上它却很容易造成混乱,因为你处理的是指针,所以要小心谨慎!聪明的程序员脑筋一转便会很快地意识到case 2和case 3可以合二为一,但这里的代码易读性更好。下面我们来看一看节点的删除。
删除节点
删除节点比插入节点复杂,因为指针和内存都要重新定位和分配。大多数情况下,只需删除指定的一个节点。但这个节点的位置可能是头部、尾部或中间,因此你必须编写一个通用的算法来处理所有可能的情况。如果你没有将所有的情况都考虑进去并进行测试,那后果将是非常糟糕的!
一般而言,这个算法必须能够按所给定的关键字搜索链表、删除节点并释放其占用的内存。此外,该算法还必须能够修复指向该被删除节点的指针和该节点所指向的下一个节点的指针。看一看图11-3便会一目了然。
图11-3:从链表中删除节点
下面这段代码可以基于关键字ID,完成删除任意节点的操作:
// again this function will modify the globals
// head and tail (possibly)
int Delete_Node(int id) // node to delete
{
// this function deletes a node from
// the linked list given its id
NODE_PTR curr_ptr = head, // used to search the list
prev_ptr = head; // previous record
// test if there is a linked list to delete from
if (!head)
return(-1);
// traverse the list and find node to delete
while(curr_ptr->id != id && curr_ptr)
{
// save this position
prev_ptr = curr_ptr;
curr_ptr = curr_ptr->next;
} // end while
// at this point we have found the node
// or the end of the list
if (curr_ptr == NULL)
return(-1); // couldn't find record
// record was found, so delete it, but be careful,
// need to test cases
// case 1: one element
if (head==tail)
{
// delete node
free(head);
// fix up pointers
head=tail=NULL;
// return id of deleted node
return(id);
} // end if
else // case 2: front of list
if (curr_ptr == head)
{
// move head to next node
head=head->next;
// delete the node
free(curr_ptr);
// return id of deleted node
return(id);
} // end if
else // case 3: end of list
if (curr_ptr == tail)
{
// fix up previous pointer to point to null
prev_ptr->next = NULL;
// delete the last node
free(curr_ptr);
// point tail to previous node
tail = prev_ptr;
// return id of deleted node
return(id);
} // end if
else // case 4: node is in middle of list
{
// connect the previous node to the next node
prev_ptr->next = curr_ptr->next;
// now delete the current node
free(curr_ptr);
// return id of deleted node
return(id);
} // end else
} // end Delete_Node
注意代码中包括了许多特殊的情况。尽管每一种情况的处理都很简单,但我还是希望提醒读者,一定要考虑周全,不放过每一种可能的情况。
最后,你或许已经注意到删除链表内部节点的操作极富戏剧性。这是因为这个节点一旦被删除,就无法恢复。我们不得不跟踪前一个NODE_PTR以跟踪末尾的节点。可以使用如图11-4所示的双向链表来解决这个问题及其他类似的问题。
图11-4:双向链表
双向链表的优点在于你可以在任何位置从两个方向遍历链表节点,可以非常容易地实现节点的插入和删除。数据结构上,惟一的改变就是添加了一个链接字段,如下所示:
typedef struct NODE_TYP
{
int id; // id number of this object
int age; // age of person
char name[32]; // name of person
// more fields go here
NODE_TYP *next; // link to the next node
NODE_TYP *prev; // link to previous node
} NODE, *NODE_PTR;
应用双向链表,你可以从任一个节点向前或向后搜索,所以和节点插入和删除有关的跟踪节点操作大大简化。控制台程序DEMO11_1.CPP|EXE便是一个简单的链表操作程序。它可以实现插入节点、删除节点和遍历链表。
注意
DEMO11_1.CPP是一个控制台程序而非标准的Windows .EXE程序。所以在编译前应将编译器设定为控制台应用程序。当然,这里没用到DirectX,因此也不必加载任何DirectX的.LIB文件。
算法分析
算法设计与分析通常是高级的计算机科学知识。但我们至少要掌握一些常识般的技巧和思想,以利于我们编写比较复杂的算法。
首先,要知道一个好的算法比所有的汇编语言或优化器都更好。例如,在前面说过,调整一下数据的顺序便能够减少按元素的幅度搜索数据所花费的时间。因此所应掌握的原则是:选择一个可靠的、适合问题和数据的算法,而与此同时,还要挑选一种易于该算法访问和处理的数据结构。
例如,假如你总是使用线性的数组,你就不要指望能够进行优于线性搜索时间的搜索(除非你使用第二个数据结构)。但如果使用排序的数组,搜索时间就会缩短成对数级别。
编写一个好算法的第一步是掌握一些算法分析知识。算法分析技术又叫渐近分析(Asymptotic Analysis),通常是基于微积分的。我不想过多地深入,所以只介绍一些概念。
分析一个算法的基本思想是看一看n个元素时主循环的执行次数。这里n可代表任何数据结构的元素数目。这是算法分析最重要的思想。当然,有了好的算法后,每次的执行时间、初始化的系统开销也同样重要,但我们从循环执行的次数开始。让我们看一看下面两个例子:
for (int index=0; index {
// do work, 50 cycles
} // end for index
在这个例子中,程序执行n (=50)次循环,因此执行时间就是n阶即O(n)。O(n)称为大O记法,它是一个上限值,也是对执行时间的一个很粗糙的上限估计。假如要更精确一点,你知道其内部的计算需要50次循环,所以整个执行时间就是:
n*50 cycles
对吗?错了!如果要计算循环时间,你应当将循环自身花费的时间包括进去。这些时间包括变量的初始化、比较、增量和循环的跳转。将这些时间加进去,如下式所示:
Cyclesinitialization+(50+Cyclesinc+Cyclescomp+Cyclesjump)*n
上式是一个较好的估计。这里,Cyclesinc、Cyclescomp和Cyclesjump分别代表增量、比较和跳转所需的周数。在奔腾级的处理器上,其值约为1~2周。因此,在这种情况下,循环本身所花费的时间和程序内部工作循环所需用的时间一样多。这一点是非常重要的。
比如,许多游戏程序员在处理有关像素绘图的问题时,将其编写成函数而非一个宏或内联代码。因为像素绘图函数是如此简单,以至于调用这个函数比直接画图所需时间还要多!所以在设计循环时必须确保循环内部有足够的工作,而且循环运行所需时间要远大于循环自身所花费的时间。
现在让我们来看一看另一个例子——它的时间复杂度高于n:
// outer loop
for (i=0; i {
// inner loop
for (j=1; j<2*n; j++)
{
// do work
} // end for j
} // end for i
在这个例子中,我们假定循环中工作部分执行所需时间远大于实现循环机制所花费的时间,这样我们就不考虑循环自身花费时间而只考虑循环执行的次数。这个程序的外部循环次数为n次,内部循环的次数为2n-1次,所以内部代码总的执行次数为:
n*(2*n-1) = 2*n2-n
上式由两项构成。2n2是主项,其值要远大于后一项,并且随n取值的增加,两项的差值也增大。如图11-5所示。
.
图11-5:2n2-n的增长速率
当n较小比如n=2时,上式的值为:
2*(2)2 - 2 = 6
在该情况下,n这项减去总花费时间的25%。但当n的值增大时,比如n=1000:
2*(1000)2 - 1000 = 1999000
这时,n这项只减去总花费时间的0.5%,可以忽略不计。现在读者该明白了为什么2*n2项是主项或更简单地说n2是主项。所以,这时的算法复杂度是O(n2),这是非常糟糕的,以n2运算的算法是不能令人满意的,因此当你提出这样一个算法时,你最好从头再来!
上述都是为渐近分析作准备的。最低要求是:你必须能够粗略地估计你的游戏程序中循环的执行时间。这将有助于你挑选算法和所需编码空间。
递归
我们下一个所要探讨的主题是递归(Recursion)。递归是一种应用归纳的方法求解问题的技术。递归的基本含义是把许多问题连续分解成同一形式的简单问题,直到能够真正的求解为止。然后将这些小问题进行归纳、组合进而使整个问题得到解决。听起来是不是很美妙?
在计算机编程中。我们通常使用递归算法来实现搜索、排序和一些数学计算。递归的前提非常简单:编写一个函数,该函数具有调用自己来求解问题的能力。是不是听起来不可思议?其关键在于该函数调用自己时,就在堆栈里创建一组新的局部变量,所以相当于一个新的函数被调用。你惟一需要注意的是函数不能溢出堆栈,并且要有终止条件,程序结束时还要有结束处理代码以保证堆栈通过return()释放空间。让我们看一个标准的实例:阶乘的计算。一个数的阶乘写作n!,其含义如下:
n! = n*(n-1)*(n-2)*(n-3)*...(2)*(1)
并有0! = 1! = 1
Thus, 5! is 5*4*3*2*1.
这样,5!就是5*4*3*2*1。
以下是用通常的方法编写的计算代码:
// assume integers
int Factorial(int n)
{
int sum = 1; // hold result
// accumulate product
while(n >= 1)
{
sum*=n;
// decrement n
n--;
} // end while
// return the result
return(sum);
} // end Factorial
看上去非常简单。如果输入0或1,则计算结果为1。若输入3,则其计算顺序如下:
sum = sum * 3 = 1 * 3 = 3
sum = sum * 2 = 3 * 2 = 6
sum = sum * 1 = 6 * 1 = 6
显然,计算结果正确无误。因为3! = 3*2*1。
以下是采用递归方法编写的程序:
int Factorial_Rec(int n)
{
// test for terminal cases
if (n==0 || n==1) return(1);
else
return(n*Factorial_Rec(n-1));
} // end Factorial_Rec
这个程序并不怎么复杂。我们看看当n分别为0和1时,程序是如何运行的。在这两种情况下,第一个if语句为TRUE,就返回值1并退出程序。但当n>1时,奇妙的事情就发生了。这时,执行else语句并返回该函数调用自身(n-1)次后的值。这就是递归过程。
当前函数变量的取值仍在堆栈里保存,下次调用该函数就相当于调用一个以一组新变量为参数的函数。代码中第一个return语句在进行下一个调用前不能执行完毕,就这样一直循环下去直到执行结束语句为止。
让我们来看一看n=3时,每次迭代时变量n的实际数值。
1.
第一次调用函数Factorial_Rec(3),
函数开始执行return语句:
return(3*Factorial_Rec(2));
2.
第二次调用函数Factorial_Rec(2),
函数又开始执行return语句:
return(2*Factorial_Rec(1));
3.
第三次调用函数Factorial_Rec(1),
这次函数执行结束语句并返回值1:
return(1);
现在奇妙的是,1被返回给第二次调用的函数Factorial_Rec(),如下所示:
return(2*Factorial_Rec(1));
这也就计算出了
return(2*1);
随之这一数值便被返回给第一次调用的函数,如下所示:
return(3*Factorial_Rec(2));
这也就计算出了
return(3*2);
And presto, the function can finally return with 6—which is indeed 3! That's recursion. Now, you might ask which method is better—recursion or non-recursion? Obviously, the straightforward method is faster because there aren't any function calls or stack manipulation, but the recursive way is more elegant and better reflects the problem. This is the reason why we use recursion. Some algorithms are recursive in nature, trying to write non-recursive algorithms is tedious, and in the end they become recursive simulations that use stacks themselves! So use recursion if it's appropriate and simplifies the problem. Otherwise, use straight linear code.
随之函数最终得到返回值6即3!。这便是递归过程。这时你会问哪种方法更好一些呢——是递归法还是非递归法?很明显,直接方法执行速度要快一些,因为没有涉及到任何函数调用或堆栈操作。但递归方法更优雅,能更好地反映问题的本质。这就是我们使用递归的原因。有些算法本质上是递归的,为之编写非递归算法会非常冗长,而且最终也必须使用堆栈进行递归模拟。如果适于简化问题,就使用递归法编程;否则就使用直接方法。
例如,看一下程序DEMO11_2.CPP|EXE。该程序实现了阶乘算法。注意一下阶乘的溢出速度。看看你的机器能计算到多大阶乘。绝大多数的机器可以计算到69!不骗你。
数学
我们来递归地实现一下菲波那契Fibonacci算法。第n个Fibonacci数列元素fn=fn-1+fn-2,另外,f0=0,f1=1,那么,f2=f1+f0=1,f3=f2+f1=2。所以整个Fibonacci序列为:0,1,1,2,3,5,8,13…数一数向日葵中一圈圈的种子数目,恰好是Fibonacci序列。
树结构
接下来我们要讨论的高级数据结构是:树结构(tree)。通常人们使用递归算法来处理树结构,因此我在上面论述中特别提到了递归。图11-6图示了一些不同的树状数据结构。
图11-6:树的拓扑结构
人们发明树结构,用于储存和搜索海量的数据。最常见的树结构是二叉树,也叫作B-树或二分查找树(BST)。树结构是从单一根节点发散出的树状结构,包含许多子节点。每个节点可以派生出一个或两个子节点(兄弟节点,Sibling),二叉树因此而得名。而且我可以讨论一个二叉树的阶(Order),即该树一共有几层。图11-7所示的是不同层次的二叉树。
图11-7:一些不同阶的二叉树
关于树结构,有趣的是信息查询的速度很快。绝大多数二叉树使用单一的关键字来查询数据。例如,假定你想创建一个包含游戏对象记录的二叉树,且每一个游戏对象具有多个属性,你可能会使用游戏对象的创建时间作为关键字,或将数据库中的每一节点定义为代表一个人。下面是可以用来保存单个人的节点的数据结构:
typedef struct TNODE_TYP
{
int age; // age of person
char name[32]; // name of person
NODE_TYP *right; // link to right node
NODE_TYP *left; // link to left node
} TNODE, *TNODE_PTR;
注意树节点与链表节点的相似之处!它们之间惟一的不同是使用数据结构并构造树的方法。看一看上例,假如有五个对象(人),其年龄分别为5、25、3、12和10。图11-8表示包含该数据的两个不同的二叉树。不过,你可以做得更好,比如在插入算法中根据数据插入顺序来维持二叉树的某种属性。
图11-8:数据集合 年龄 {5,25,3,12,10} 的二叉树编码
注意
Of course, the data in this example can be anything you want.
当然,在本例中,数据可以定义为任何值。
注意在建立如图11.8所示的二叉树时,使用了如下约定:任一节点的右子节点总是大于或等于该节点本身,左子节点总是小于其本身。你还可以使用其他的约定,只要保持一致性。
二叉树可以存储海量数据,而且应用“二分查找法”可以快速地检索数据。这是二叉树的优越性所在。比如,如果一个二叉树带有一百万个节点,至多进行20次比较便可以检索到数据!这是不是棒极了?其原因在于检索中的每次迭代,搜索空间的节点数都减少一半。从根本上说,假如有n个节点,那么平均搜索次数为log2 n,运行时间为O(log2 n)。
注意
上述所说的搜索时间只适于平衡二叉树——即每一层次均含有相等的左右子节点的二叉树。如果二叉树不是平衡的,那么它就退化为一个链表,而搜索时间也退化为一个线性函数。
B树第二个非常酷的属性是你可以定位子树并单独处理它。该子树仍然具有B树的所有属性。因此,如果你知道检索的位置,便可以搜索子树检索你所需要的东西。这样一来,你便可以创建树的树,或建立子树的索引表,而不必处理整个树。这在3D场景建模中是非常重要的。你可以为整个游戏空间建立一个树结构,而以数百棵子树来表示空间中的各个房间。你还可以创建另一个树结构来指代具有偏序排列的指向子树根指针的链表。如图11-9所示。有关这方面更多的内容会在本书的以后章节中论及。
图11-9:在B树结构上使用一个二级索引表
最后,让我们谈一谈何时使用树结构。我建议,当所处理的问题或数据类似于树结构时使用树结构。比方说,当你随手画出问题的草图,而发现其中具有左右分支的话,树结构明显应该是合适的。
建立二分查找树(BST)
由于应用于B树的算法在本质上是递归的,所以这一节的主题比较复杂。让我们先快速地浏览一下二叉树的一些算法,然后写一些代码,并完成一个演示程序。
与链表相似,创建BST有两种方法:树结构的根节点可以为哑元,也可以是实际有效的节点。我更喜欢以实际节点作为根节点。因此,一个空树中没有任何东西,而本应指向根节点的指针现在为NULL。
TNODE_PTR root = NULL;
Okay,要将数据插入BST,你必须确定插入数据所使用的关键字。在本例中,你可以使用人的年龄或名字。因为上面这些例子都使用年龄作为关键字,所以这里也使用年龄作为关键字。然而,以名字作为关键字更简单,你只需使用词典式的比较函数如strcmp()就可以确定名字的顺序。无论如何,下面这段代码将节点插入BST:
TNODE_PTR root = NULL; // here's the initial tree
TNODE_PTR BST_Insert_Node(TNODE_PTR root, int id, int age, char *name)
{
// test for empty tree
if (root==NULL)
{
// insert node at root
root = new(TNODE);
root->id = id;
root->age = age;
strcpy(root->name,name);
// set links to null
root->right = NULL;
root->left = NULL;
printf("/nCreating tree");
} // end if
// else there is a node here, lets go left or right
else
if (age >= root->age)
{
printf("/nTraversing right...");
// insert on right branch
// test if branch leads to another sub-tree or is terminal
// if leads to another subtree then try to insert there, else
// create a node and link
if (root->right)
BST_Insert_Node(root->right, id, age, name);
else
{
// insert node on right link
TNODE_PTR node = new(TNODE);
node->id = id;
node->age = age;
strcpy(node->name,name);
// set links to null
node->left = NULL;
node->right = NULL;
// now set right link of current "root" to this new node
root->right = node;
printf("/nInserting right.");
} // end else
} // end if
else // age < root->age
{
printf("/nTraversing left...");
// must insert on left branch
// test if branch leads to another sub-tree or is terminal
// if leads to another subtree then try to insert there, else
// create a node and link
if (root->left)
BST_Insert_Node(root->left, id, age, name);
else
{
// insert node on left link
TNODE_PTR node = new(TNODE);
node->id = id;
node->age = age;
strcpy(node->name,name);
// set links to null
node->left = NULL;
node->right = NULL;
// now set right link of current "root" to this new node
root->left = node;
printf("/nInserting left.");
} // end else
} // end else
// return the root
return(root);
} // end BST_Insert_Node
首先你要测试是否是空树,如果是空树就创建其根节点。如有必要,应使用最先插入BST的那项内容创建根节点。因此,最先被插入BST的那项内容或记录就代表搜索空间中靠近中间的项,以便树能很好地平衡。总之,如果树有超过一个的节点,你必须遍历该树,至于将节点插入左子树或右子树则取决于你要插入的记录。当你遇到树结构的叶节点或终止枝时,便可以将新节点插入该处。
root = BST_Insert_Node(root, 4, 30, "jim");
图11-10示意了如何将“Jim”插入一个树中。
图11-10:在BST中插入元素
将新节点插入BST这一过程的执行效率和在BST中搜索节点相当,因此,一次插入操作所需的平均时间约为O(log2n),而最坏的情况是O(n)(当关键字以降序线性排列时)。
搜索BST
一旦建立了BST,就是进行数据的搜索的时候了。当然这会用到许多递归处理方法,应加以关注。搜索BST有三种方法:
? 前序——访问节点、然后按前序搜索左子树,最后按前序搜索右子树。
? 中序——按中序搜索左子树,然后访问节点,最后按中序搜索右子树。
? 后序——按后序搜索左子树,然后按后序搜索右子树,最后访问节点。
注意
左和右是任意的,关键在于访问和搜索的顺序。
参见图11-11。该图显示了一个简单的二叉树及三种搜索顺序。
图11-11:前序、中序、后序搜索方式的节点访问次序
知道了这三种顺序,你可以为之编写相当简单的递归算法来实现它们。当然,遍历二叉搜索树的目的是找到目标并将其返回。下面的函数便实现了二叉树遍历的功能。你可以为之增加停止代码以便搜索到目标关键字时结束程序。不过,现在你比较在意搜索方法:
void BST_Inorder_Search(TNODE_PTR root)
{
// this searches a BST using the inorder search
// test for NULL
if (!root)
return;
// traverse left tree
BST_Inorder_Search(root->left);
// visit the node
printf("name: %s, age: %d", root->name, root->age);
// traverse the right tree
BST_Inorder_Search(root->right);
} // end BST_Inorder_Search
以下是前序搜索代码:
void BST_Preorder_Search(TNODE_PTR root)
{
// this searches a BST using the preorder search
// test for NULL
if (!root)
return;
// visit the node
printf("name: %s, age: %d", root->name, root->age);
// traverse left tree
BST_Inorder_Search(root->left);
// traverse the right tree
BST_Inorder_Search(root->right);
} // end BST_Preorder_Search
以下是后序搜索代码:
void BST_Postorder_Search(TNODE_PTR root)
{
// this searches a BST using the postorder search
// test for NULL
if (!root)
return;
// traverse left tree
BST_Inorder_Search(root->left);
// traverse the right tree
BST_Inorder_Search(root->right);
// visit the node
printf("name: %s, age: %d", root->name, root->age);
} // end BST_Postorder_Search
就这么简单,像不像变戏法?假设你已建立了一棵二叉树,试着用这个调用进行中序遍历。
BST_Inorder_Search(my_tree);
提示
我很难形容类树结构在3D图形学中有多重要,希望你已经理解了上述内容。否则,当你构造二分空间分区(????)来解决渲染问题时,你将会陷入指针递归的苦海中。:)
你注意到我省略了如何删除一个节点。我是有意这样做的。因为删除一个节点非常复杂,有可能破坏子树的父节点和其与子节点间的连接。我想,删除节点就留作给读者的一个练习好了。这里我向大家建议一本好的数据结构参考书——由Sedgewick撰写,Addison Wesley 出版社出版的《Algorithms in C++》(该书中文版《算法I~IV(C++实现)——基础、数据结构、排序和搜索(第三版)》已由中国电力出版社出版。——编者),这本书深入讨论了树结构及其相关算法。
最后,读者可以检验一下二叉搜索树的一个实例程序——DEMO11_3.CPP|EXE。该程序允许你创建一个二叉搜索树并使用三种算法遍历它。这也是一个控制台程序,因此需要正确地编译它。
优化理论
比起编写任何其他程序来说,编写游戏更重视性能优化。视频游戏永无止境地不断突破硬件和软件的极限。游戏编程人员总是希望加入更多的生物、特效、声音以及更好的智能等等。因此,优化至关重要。
在这一节,我们将讨论一些优化技术以帮助你进行游戏编程。如果你对此有浓厚的兴趣,有很多关于该方面的书可以参考,如由Addison Wesley出版社出版、Rick Booth编著《Inner Loops》,由Coriolis 集团出版、Mike Abrash 编著的《Zen of Code Optimization》,AP出版社出版、Mike Schmit编著的《Pentium Processor Optimization》。
运用你的头脑
编写优化代码的第一要旨是理解编译器和数据结构,以及你编写的C/C++程序是如何被最终转换为可执行的机器代码的。基本思想是使用简单的程序设计技巧和数据结构。你的代码越复杂、设计越精巧,编译器将其变换为机器代码就越困难,所以(在大多数情况下)其执行速度也就越慢。以下是编程时需要遵循的基本原则:
? 尽可能使用32位数据。8位数据虽然占用空间较少,但英特尔的处理器是以32位为基准的,并针对32位数据进行了优化。
? 对于频繁调用的小函数而言,应声明为内联函数。
? 尽可能使用全局变量,但避免产生可读性差的代码。
? 避免使用浮点数进行加法和减法运算,因为整数单元通常比浮点单元运算快。
? 尽可能使用整数。尽管浮点处理器几乎和整数处理一样快,但整数更精确。所以如果你不需要精确的小数位,就使用整数。
? 将所有的数据结构均调整为32个字节对齐。在大多数编译器上你可以使用编译器指示字来手工完成,或在代码中使用#pragma。
? 除非是简单类型的参数,否则尽可能不使用值传递的方式传递参数。应当使用指针。
? 在代码中不要使用register关键字。尽管Microsoft声称它能够加快循环,但这会造成编译器没有足够可用的寄存器,结果是生成糟糕的代码。
? 如果你是位C++程序员,用类和虚函数是可以的。但软件的继承和层次不要过多。
? 奔腾级的处理器使用一个内部数据和代码缓存。了解这一点后,要确保函数代码短小以适应缓存的大小(16KB至32KB以上)。此外,在储存数据时,以其将被访问的方式进行存储。这样可以提高缓存的命中率、从而减少访问主存或二级缓存的次数。须知,主内存或二级缓存的访问速度要比内部缓存的访问速度慢10倍。
?
? 奔腾级的处理器具有类似RISC的内核,因此擅长处理简单指令,并能够在数个执行单元内同时处理二个以上的指令。因此,不推荐在一行代码中书写晦涩难懂的代码,最好编写较简单的代码。即使你可以将这些代码合并成具有同样功能的一行代码,也尽量改成简单的。
数学技巧
由于游戏编程中大量的工作在实质上是数学问题,因此了解执行数学运算的更好方法是非常值得的。有许多通用的技巧和方法可供你利用,以提高程序运行速度:
参与整数运算的数必须是整数,参与浮点运算的数必须是浮点数。类型转换必然降低性能。所以除非是迫不得已,否则不要进行类型转换。
通过左移位操作可以实现整数与2的任何次幂的乘法运算。同样地,通过右移位操作可以实现整数与2的任何次幂的除法运算。对于整数与非2的次幂的数的相乘和相除可以转换为和运算或差运算。例如,640虽不是2的整数次幂,但512和128是2的整数次幂;所以当某整数与640相乘最好的方法是进行如下变换:
product=(n<<7) + (n<<9); // n*128 + n*512 = n*640
不过,如果处理器在1至2个时钟周期内就可以完成乘法运算,这种优化就失去意义了。
如果你的算法中应用到矩阵操作,要充分利用矩阵的稀疏性——一些元素为零。
在创建常量时,确保其为恰当的类型以防编译器编译出错或将其强迫转换为整数类型。最好是使用C++中新的const指示字。例如:
const float f=12.45;
避免使用平方根、三角函数或任何复杂的数学函数。一般而言,这些复杂函数的计算都可以根据特定的假设或近似而找到一个简单的方法来实现。但即使结果还是不理想,你仍然可以做一张查找表。我在后面将会提及该表。
如果你要将一个大的浮点数组清零,要按如下方式使用memset():
memset((void *)float_array,0,sizeof(float)*num_floats);
然而事实上也只有这种情况下才能这样做,因为浮点数是以IEEE格式编码的,故而只有整数零和浮点零的数值才完全相等。
在执行数学运算时,看一看在编码前能否手工先将表达式简化。比如,n*(f+1)/n等于(f+1),因为除法和乘法的运算结果将n 消去了。
如果你要执行复杂的数学计算,而且你在下面的代码中需要再次用到计算结果,那么就将其暂存在高速缓存中。如下所示:
// compute term that is used in more than one expression
float n_squared = n*n;
// use term in two different expressions
pitch = 34.5*n_squared+100*rate;
magnitude = n_squared / length;
最后,很重要的一点是确保将编译器的选项设定为打开浮点协处理器,这样编译出的代码的执行速度较快。
定点运算
几年以前,大多数3D引擎都采用定点运算来进行3D中大量的变换和数学运算。这是因为处理器对浮点的支持没有对整数的支持快,甚至在奔腾处理器上也是如此。但是今天,奔腾Ⅱ、Ⅲ和Katmai处理器拥有更好的浮点能力,所以定点运算不再那么重要。
然而在许多情况下,由浮点到整数的光栅变换仍然很慢,因此在内部循环的加法和减法运算中使用定点运算仍是一个较好的选择。在低档的奔腾机器上,这类运算依然比浮点运算要快,因此你可以运用技巧快速地从定点数中提取出整数部分,而不必进行float到int的类型转换。
无论如何,这些都具有一定的不确定性。今天,使用浮点来处理任何运算通常都是最好的选择,但是多了解一些定点运算总还是有帮助的。我的观点是使用浮点来处理所有的数据表示和变换,对于低水平的光栅变换可以分别试一试定点运算和浮点运算,看一看那种处理方法最快。当然,如果你使用纯硬件加速,无需多虑,只要一直使用浮点数即可。
记住了这些,下面让我们来看看如何表示定点数。
定点数的表示
所有的定点数学实际上是以整数尺度为基础的。比如,我们想用一个整数来表示10.5。这做不到,因为没有小数位。你可以将其截断为10.0或将其舍入为11.0,但10.5不是一个整数。但如果你将10.5放大10倍,10.5就变成了105.0,这是一个整数。这便是定点数的基础。你可以采用某一比例系数来对数值进行缩放,并在进行数学计算时将比例系数考虑进去。
由于计算机是二进制的,大部分游戏程序倾向于使用32位整数(或int),以16.16的格式来表示定点数。如图11-12所示。
图11-12:一种16.16定点数表示法
你可以将整数部分放在高16位,小数部分置于低16位。这样你已将整个数值放大为原来的216即65536倍。另外,为提取出一个定点数的整数部分,你可以移位并屏蔽掉高16位;为得到小数部分,你可以移位并屏蔽掉低16位。
以下是一些常用的定点数的类型:
#define FP_SHIFT 16 // shifts to produce a fixed-point number
#define FP_SCALE 65536 // scaling factor
typedef int FIXPOINT;
在定点与浮点之间转换
有两类数需要转换为定点数:整数和浮点数。这两类转换是不同的,需分别考虑。对于整数,直接用其二进制的补码表示。所以你可以移位操作来放大这个数,从而将其转换为定点数。而对于浮点数,由于其使用IEEE格式,四字节中有一个尾数和指数,因此移位将破坏这个数。因此,必须使用标准的浮点乘法来进行转换。
数学
二进制补码是一种表示二进制整数的方法。因此整数和负数均可以用这种方法表示,并且在该集合上数学运算都是正确的。一个二进制数的补码是指将该二进制的每一位取反并与1进行加运算所得的数。在数学意义上,假定你想求6的补码,先取反码得-6,再与1相加即-6+1或~0110+0001=1001+0001=1010。该数就是10的普通二进制表示,但同时也是-6的补码表示。
以下是将整数转换为定点数的宏:
#define INT_TO_FIXP(n) (FIXPOINT((n << FP_SHIFT)))
例如:
FIXPOINT speed = INT_TO_FIXP(100);
下面是将浮点数转换为定点数的宏:
#define FLOAT_TO_FIXP(n) (FIXPOINT((float)n * FP_SCALE))
例如:
FIXPOINT speed = FLOAT_TO_FIXP(100.5);
提取一个定点数也很简单。下面是从高16位提取整数部分的宏:
#define FIXP_INT_PART(n) (n >> 16)
至于从低16位提取小数部分,则只需将整数部分屏蔽掉即可:
#define FIXP_DEC_PART(n) (n & 0x0000ffff)
当然,如果你聪明的话,可以不需要进行转换,只要使用指针即时地访问高16位和低16位即可。如下所示:
FIXPOINT fp;
short *integral_part = &(fp+2), *decimal_part = &fp;
指针integral_part和decimal_part总是指向你所需的16位数。
精度
此刻一个问题突然出现在你的脑海中:二进制小数部分是怎么回事?通常你不必理会这个问题,它仅在运算时用到。一般而言,在光栅转换循环或其他循环中只需整数部分即可。但因为以2为基数,所以小数部分也是以2为基数的小数。如图11-12所示。例如二进制小数
1001.0001 是 9 + 0*1/2 + 0*1/4 + 0*1/8 + 1*1/16 = 9.0625
这给我们带来了精度的概念。上式使用了四位二进制数,其精度大约与1.5位十进制数精度相同,或者说是±0.0625。对于16位二进制数,则可以精确到1/216=0.000015258或万分之一。这一精度可以满足绝大部分要求。另一方面,如果你仅用16位来存储整数部分,其存储的数值范围为-32767~32768(无符号数可到65535)。这个限制在巨大的宇宙或数字空间将成为问题,所以要提防溢出问题。
加法和减法
定点数的加法和减法运算比较简单。你可以采用标准的+和-运算符:
FIXPOINT f1 = FLOAT_TO_FIX(10.5),
f2 = FLOAT_TO_FIX(-2.6),
f3 = 0; // zero is 0 no matter what baby
// to add them
f3 = f1 + f2;
// to subtract them
f3 = f1 – f2;
注意
由于定点数以二进制的补码表示,所以在进行上述运算时正负数都无问题。
乘法和除法运算
乘法和除法运算比加法和减法运算稍微复杂一些。问题在于定点数是被放大了的数。当进行乘法运算时你不仅乘上了定点数,同时也乘上了放大系数。看一看下述源代码:
f1 = n1 * scale
f2 = n2 * scale
f3 = f1 * f2 = (n1 * scale) * (n2 * scale) = n1*n2*scale2
看到额外的放大系数吗?为矫正这个问题,你需要除以或移出放大系数的平方scale2。这样,两定点数相乘的运算如下:
f3 = ((f1 * f2) >> FP_SHIFT);
定点数的除法也会遇到同乘法类似的问题,但结果相反。如下所示:
f1 = n1 * scale
f2 = n2 * scale
假设这样,则:
f3 = f1/f2 = (n1*scale) / (n2*scale) = n1/n2 // no scale!
注意在运算过程中消去了放大系数因而得到的是非定点数。这在某些情况下是非常有用的。但若要成为定点数,必须进行放大:
f3 = (f1 << FP_SHIFT) / f2;
警告
定点数的乘法和除法具有上溢和下溢问题。就乘法而言,最坏的情况是我们不得不使用64位。同样,对于除法分子的高16位通常丢失,仅剩下小数部分。解决的方法是使用24.8位格式或全64位格式进行运算。这可以使用汇编语言实现,因为Pentium及以上的处理器支持64位运算。或者,你也可以稍微改变一下格式,使用24.8位格式。这样可以满足定点数的乘法和除法运算而不至于一下子丢失所有信息。但是,你的精度仍将大幅度下降。
程序DEMO11_4.CPP|EXE是一个定点数运算的例子。该程序允许你输入两个小数,然后执行定点数运算并观察其计算结果。注意乘法和除法运算结果可能不正确,这是因为计算结果采用的是16.16格式而非64位格式。为修正这一点,你可以改用24.8格式重新编译程序。条件编译由程序顶端的两个#defines控制:
// #define FIXPOINT16_16
// #define FIXPOINT24_8
删掉其中一行的注释符,编译器便可以开始编译了。这是一个控制台应用程序,因此如黑人电影导演Spike Lee说的那样:“为所欲为……”
循环体展开
下一个优化技巧是循环体展开。在8位和16位时代,这是最好的优化技术之一,但今天它可能带来相反的后果。展开循环意味着分解一个重复多次的循环,并对每一行手工编程。举例如下:
// loop before unrolling
for (int index=0; index<8; index++)
{
// do work
sum+=data[index];
} // end for index
这个循环的问题是工作花费的时间小于循环增量、比较和跳转所花费的时间。如此一来,循环本身所需时间是工作代码的二或三倍。你可以进行如下的循环展开:
// the unrolled version
sum+=data[0];
sum+=data[1];
sum+=data[2];
sum+=data[3];
sum+=data[4];
sum+=data[5];
sum+=data[6];
sum+=data[7];
这样就快多了。但是有以下两点需要说明:
如果循环体比循环结构复杂得多,那就没有必要将其展开。例如,如果你要在循环体内计算平方根,就没有必要展开了。
由于奔腾处理器带有内部缓存,将一个循环展开太多会导致内部缓存的拥塞。这将是灾难性的,并会导致代码异常结束。我的建议是视情况而定,循环展开的次数宜为8至32次之间。
查找表
这是我个人最喜爱的优化技巧。查表法是预先计算出程序运行时的一些结果。在程序启动时简单地计算出所有可能的结果,然后再运行游戏。例如,假定你需要计算出从0~359间各个角度的正弦和余弦值。如果使用浮点处理器来计算sin()和cos(),将很费时间。但使用查表方法程序,则只需几个CPU周期便可以得出各角度的正弦和余弦值。举例如下:
// storage for look up tables
float SIN_LOOK[360];
float COS_LOOK[360];
// create look-up table
for (int angle=0; angle < 360; angle++)
{
// convert angle to radians since math library uses
// rads instead of degrees
// remember there are 2*pi rads in 360 degrees
float rad_angle = angle * (3.14159/180);
// fill in next entries in look-up tables
SIN_LOOK[angle] = sin(rad_angle);
COS_LOOK[angle] = cos(rad_angle);
} // end for angle
以下是一个利用该张查找表的例子,该代码执行的结果是画一个半径为10的圆:
for (int ang = 0; ang<360; ang++)
{
// compute the next point on circle
x_pos = 10*COS_LOOK[ang];
y_pos = 10*SIN_LOOK[ang];
// plot the pixel
Plot_Pixel((int)x_pos+x0, (int)y_pos+y0, color);
} // end for ang
当然,查找表需要占用一定的内存,但这样做也是值得的。“如果你能够预先算出结果,就将其放进查找表中。”这是我的座右铭。你可以思考一下DOOM、Quake以及我的最爱Half-Life是怎样工作的?
汇编语言
我想讨论的最后一种优化是使用汇编语言。你或许已拥有了很酷的算法和好的数据结构,但你还是希望更有效率。手工编写汇编语言不再能使代码如当年运行在8/16位处理器上那般,一下子快上1000倍,但它一般总能使你的代码运行速度提高2至10倍。这说明手工编写汇编语言代码还是非常值得的。
当然,你必须确保只转换游戏程序中需要转换的代码部分。注意不需要优化主菜单程序,因为那样只是浪费时间。用一个性能测试工具(Profiler)测试一下当你的游戏程序运行的时候,CPU时间在何处被消耗殆尽(可能在图形部分),然后定位该处并将其以汇编语言改写。我建议使用Intel的Vtune作为性能测试工具。
在过去(几年前),大部分编译器不支持内联汇编。如果有,也很难用!如今Microsoft、Borland和Watcom的编译器都提供内联汇编的支持,用来编写数十句到几百句的小程序就如同单独使用汇编程序一样得心应手。所以我建议如果需要汇编语句就使用内联的汇编器。下面的代码表明在使用Microsoft VC++时如何调用内联汇编:
_asm
{
.. assembly language code here
} // end asm
内联汇编器最突出的优点是它允许使用已在C/C++中定义的变量名。下面的代码示范了如何使用内联的汇编语言编写一个32位的内存填充函数:
void qmemset(void *memory, int value, int num_quads)
{
// this function uses 32 bit assembly language based
// and the string instructions to fill a region of memory
_asm
{
CLD // clear the direction flag
MOV EDI, memory // move pointer into EDI
MOV ECX, num_quads // ECX hold loop count
MOV EAX, value // EAX hold value
REP STOSD // perform fill
} // end asm
} // end qmemset
要使用这个新的函数,你只需这样调用:
qmemset(&buffer, 25, 1000);
这样从buffer的起始地址开始,1000个quads被逐一填充为值25。
注意
如果你使用的不是Microsoft VC++, 你应查看一下你所用编译器的帮助,弄明白内联汇编器所需的语法格式。在大多数情况下,它们之间只不过有些下划线不同而已。
制作演示
假如你已完成游戏程序的编写,这时需要一个演示模式(Demo Mode)。制作演示主要有两种方法:你可以自己玩这个游戏并记录你的动作,或者你可以使用一个人工智能玩家。记录自己的游戏玩法是最常见的选择。因为编写一个像真人一样过关斩将的人工智能玩家是非常困难的,而且为了给潜在的买家留下良好的印象,就必然要求人工智能玩家以非常酷的方式玩游戏,要做到这一点也是很困难的。让我们扼要地看一下这两种方法是怎样实现的。
预先记录的演示
为了记录一段演示,基本上,你要记录每一循环的各种输入设备的状态,将数据写入文件,然后将该记录文件作为游戏引擎的输入来制作演示。看一看图11-13中的A及B便一目了然了。这一方法的思路是游戏本身并不知道输入是来自键盘(输入设备)还是文件,因此这种演示只是简单地回放游戏。
图11-13:演示回放
为使其工作,你必须有一个确定性(Deterministic)的游戏策略:如果你再次玩这个游戏并且玩法相同,那么游戏人物也将做同样的事情。这意味着如同记录输入设备一样,你必须记录初始的随机数种子,以便将游戏记录的开始状态也像输入一样被记录下来。这样做确保了游戏演示将按照你记录时同样的状态播放。
记录一个游戏的最好办法并不是以一定的时间间隔对输入进行采样,而是每帧都对输入进行采样。这样一来,这个演示不论计算机快慢,回放时均能与游戏保持同步。我通常的做法是将所有的输入设备并入到一个记录中,一帧一个记录,然后将这些记录做成一个文件。我将播放演示程序的状态信息或随机数放在文件的开头,以便于载回这些数据。因此,这个回放文件如下所示:
Initial State Information
Frame 1: Input Values
Frame 2: Input Values
Frame 3: Input Values
.
.
Frame N: Input Values
初始状态信息
第1帧:输入值
第2帧:输入值
第3帧:输入值
.
.
第N帧:输入值
一旦你有了这个文件,只要简单地将游戏复位后从头播放即可。随后读入文件,仿佛这些数据是从输入设备输入的一样。游戏自身并不知道这点差别!
警告
The single worst mistake that you can make is sampling the input at the wrong time when you're writing out records. You should make absolutely certain that the input you sample and record is the actual input that the game uses for that frame. A common mistake that newbies make is to sample the input for the demo mode at a point in the event loop before or after the normal input is read. Hence, they're sampling different data! It's possible that the player might have the fire button down in one part of the event loop and not in another, so you must sample at the same point you read the input for the game normally.
你可能犯的最糟糕的一个错误是:在写出记录时,在不当的时机对输入进行了采样。事实上,应当务必确保采样和记录的输入是游戏相应的帧所实际使用的输入。一个新手常犯的错误是这样的,为游戏演示所进行的采样超前或落后于游戏的正确输入时刻。因此,所采样到的数据是不同的数据!其可能造成的结果是游戏玩家在游戏事件循环的某一部分按下了发射键,而在另一部分却松开了它。所以必须在同一处进行采样与读入输入。
由人工智能控制的演示
记录游戏的第二个方法是借助于编写的人工智能“bot”(机器人)来执行游戏,就像人们联网玩Quake一样。bot在游戏处于演示模式时会如同一个参与游戏的人工智能角色一样地玩游戏。这种方法惟一的问题(除技术复杂外)是bot可能没有展示出所有“酷”的房间、武器等等,因为它并不知道它在制作游戏的演示。另一方面,采用bot参与游戏的最大好处是每一个演示都不相同,并且这种多样性在展示游戏的时候很有价值,因为观看者不会觉得乏味。
在游戏中制作bot和制作其他的人工智能角色一样。基本上你只需将其与你的游戏输入接口连接起来并重载标准输入流即可,如图11-13的C所示。然后为bot编写人工智能算法,设定一些主要的目标,如找出迷宫的路径、射杀所见的每一个东西,或其他任务等。之后就简单了,你只需任由bot运行,直到玩家取而代之。
保存游戏的手段
在游戏编程中,编写保存游戏部分是最令人头疼的事情之一。这是游戏程序员最后才做的事情之一。关键是编写游戏的时候,就要考虑到你所编写的游戏应当为玩家提供保存游戏进度的功能。
在任何时候都能保存游戏意味着要记录游戏中每一个变量和每一个对象。因此在一个文件中,你必须记录所有的全局变量和每个对象的状态。最佳的实现途径是采用面向对象的方法来处理。与其编写一个函数去记录每个对象的状态和所有的全局变量,倒不如使每个对象知道如何将自己的状态读出并写入磁盘文件。
为保存游戏,你所要做的就是编写全局变量然后创建一个简单的函数。由函数通知游戏中的每个对象将其自身的状态写出。然后,当需要读回游戏进度的时候,你要做的就只是将这些全局变量读入系统,然后将所有对象的状态读入游戏。
用这种办法,如果你新增加了一个对象或对象类型,加载/保存过程只局限于该对象自身,而不会影响整个程序。
实现多人游戏
下一个游戏编程的花招是实现多人游戏。当然,如果你想编写一个网络游戏,那就另当别论了——尽管DirectPlay使得通信部分变得更为容易。然而,如果你希望的只是让两个或两个以上的玩家同时或轮流地玩你的游戏,那你只需增加一些额外的数据结构、稍微调整一下程序即可。
轮流
轮流的实现既简单又复杂。说其简单是因为既然你能够实现一个玩家,为了实现两个或更多玩家,只需提供多于一个的游戏玩家记录即可。说它难是因为在切换时你必须为每一个玩游戏者提供游戏保存的功能。所以通常而言,如果你的游戏需要具备轮流切换选项,你就必须在游戏中实现保存的功能。显而易见,游戏玩家在轮换的时候并不知道游戏已被保存。
根据这点,下面依次列出了两人轮流玩的游戏所需的制作步骤:
1.
开始游戏,玩家1开始。
2.
玩家1玩游戏直到结束。
3.
玩家1的游戏状态被保存,玩家2开始。
4.
玩家2玩游戏直到结束。
5.
玩家2的状态被保存(这时进行轮换)。
6.
将玩家1先前被保存的游戏重新加载,玩家1继续。
7.
回到步骤2。
你可以看到,轮换发生于步骤5,随后游戏便在两个玩家间轮流进行。假如游戏的玩家是两个以上,只需简单地在他们间轮流进行(一次只能一个人玩),直到轮流到最后一个,然后再从头开始。
分屏
实现两个或两个以上的玩家同时在同一个屏幕上玩游戏比玩家轮流交换要复杂一些。因为你不得不将游戏编写得复杂一些——将玩家间的游戏规则、冲突和交互考虑进去。而且在同一时刻多人玩的情况下,你必须为每个玩家分配指定的输入设备。这通常是指每个玩家分配一根游戏操纵杆,或一个玩家使用键盘而另一个使用游戏杆。
同一时刻多人参与的游戏还有一个问题是,一些游戏并不适于这样做。例如在卷轴游戏中,一个玩家想走这条路而另一个玩家却想走另一条路。这将造成抵触,你不得不予以考虑。因此最适于多人同时玩的是单屏幕格斗游戏,或多人为了同一个目标而走到一起的游戏。
但如果你允许玩家自由地走动,这时你可以创建如图11-14所示的分屏的显示。
图11-14:分屏游戏显示
多画面显示的惟一问题是多画面显示!你必须产生出两个或更多的游戏画面。这在技术上极具挑战性。此外,屏幕上或许没有足够的空间用于显示两幅或两幅以上的画面,因而玩家很难看到所发生的事情。但是如果你能实现分屏功能,最起码它是一个非常酷的选项……
多线程编程技术
到目前为止,本书所谈及的所有演示程序都是使用单线程事件循环和编程模型。事件循环对玩家的输入作出响应并以每秒30帧以上的速度渲染游戏画面。在对玩家作出反应的同时,游戏每秒要执行数百万次的运算,同时处理数十或数百个诸如绘制所有的物体、取得输入数据、播放音乐等小任务。图11-15展示了标准游戏循环。
图11-15:标准DOS单任务游戏循环
由图11-15中可知,游戏逻辑以串行(顺序)方式来完成各项任务。当然也有例外,比如通过中断来完成一些简单的逻辑任务,诸如音乐、输入控制等。但总地说来,游戏就是一个长长的函数调用序列。
尽管每一项任务都是顺序执行,但由于计算机足够快,其执行的结果就如同同时发生一般,这样便使游戏看上去非常流畅和真实。所以,大多数游戏程序是一个单任务执行线程——顺序执行一些操作并输出每一帧所需要的结果。这是解决问题最好的方法之一,也是DOS游戏编程的必然结果。
然而在今天,DOS时代已成为过去,所以现在是发挥Windows 95/98/ME/XP/NT/2000多线程威力的时候了,我打赌你会喜欢的!
这节内容将就Windows 95/98/NT下的执行线程(thread of execution)进行探讨。通过使用线程可以轻易地在一个应用程序中运行多个任务。在开始之前,让我们先看一些术语,这样提到它们的时候不会太突兀。
多线程编程的术语
在计算机词典里有各种各样以“multi-”开头的词语。让我们先谈一谈多处理器(Multiprocessor)和多重处理(Multiprocessing),最后再讨论多线程(multithreading)。
一台多处理器计算机是指具有多个处理器的计算机。Cray和Connection Machine就属于这类。Connection Machine计算机可以安装多达64000个处理器(构成一个超立方体网络),其中每一个处理器均用于执行代码。
对一般消费者而言,购买配置有四个PIII+处理器的计算机用于运行Windows NT可能比较实际。通常它们都是SMP(对称多处理,Symmetrical Multiprocessing)系统,即四个处理器可以对称地执行任务。实际上,情况并非总是如此,因为操作系统内核只运行在一个处理器上,但随着进程数量增加,其他任务便均匀地运行在每个处理器上。所以说,多处理器计算机的概念就是利用多个处理器来分担工作量。
对于某些系统,在每个处理器上只能执行一项任务或一个进程,而在其他系统上,如Windows NT,每个处理器上可运行数千个任务。基本上这便是多重处理——多个任务运行在一台具有单个(或多个)处理器的计算机上。
最新的概念是多线程,也是今天最令人感兴趣的术语。在Windows 95/98/NT/2000下的进程就是一个完整的程序;尽管有些时候进程不能够独立地运行,通常情况下它的确是一个应用程序。它能够拥有自己的内存空间、上下文,并独立存在。
较之进程,线程(thread)是更为简单的程序实体。线程由进程创建,彼此间各不相同,结构简单并运行在创建它们所在的进程的地址空间内。线程的美妙之处在于它们能获得尽量多的处理器时间,并存在于创建它们的父进程的地址空间内。
这意味着与线程的通信非常简单。本质上,线程恰是游戏程序员所需要的:一个执行线程并行地和其他的主程序任务一起工作,并可以访问程序中的变量。
既然带有“multi-”前缀,因此你有必要搞清楚几个概念。首先,Windows 95、98、NT和2000都是多任务/抢先式操作系统。这表明任何任务、进程或线程都不能完全控制计算机。每一项任务、进程或线程在某种程度上都可被打断或被阻塞,而允许下一个执行线程开始运行。这与Windows 3.1完全不同——在Windows 3.1不是抢先式的。如果在每个循环中没有调用GetMessage(...),其他进程就不工作。而在Windows 95/98/NT/2000下,你可以设置一个无限FOR循环,而操作系统依然能使其他任务照常运行。
而在Windows 95/98/NT/2000下,每一个进程或线程都有一个优先级,这个优先级决定了每个进程或线程在被打断之前的运行时间。所以,如果有10个相同的优先级进程,那么它们的运行时间相同或以循环的方式被处理。可是如果有一个线程具有内核级的优先级,该线程在每个循环中将获得更多的运行时间,如图11-16所示。
图11-16:具有相等或不等优先级的轮转线程执行过程
最后,问题出现了:Windows 95/98/NT/2000的多线程间有什么区别?它们之间当然有一些区别。但符合Windows 95操作系统模型的程序大多可安全地运行在其他所有平台上。这是最基础的操作系统。尽管98和NT的稳定性更好一些,但在本节里我将仍然使用Windows 95机器来运行大部分的程序实例。
为何要在游戏中使用线程?
现在这个答案是非常明显的了。事实上,我想你随时都可以列出1000件可以用线程来做的事情。然而,假如你无法做到这一点(比如你刚刚从Mountain Dew(或Sobe,我最近爱上了它)的宿醉中醒过来),下面我列出一些用到多线程编程的地方:
? 更新动画
? 产生环绕音响效果
? 控制小对象
? 查询输入设备
? 更新全局数据结构
? 创建弹出菜单和控件
上述最后一项是我经常使用的。在游戏正在运行的时候创建菜单并允许玩家改变设置,这一直是令人头疼的事。但是用线程处理起来就简单多了。
到目前为止,我依然没有回答为什么要在游戏编程中使用线程而不使用一个庞大循环和函数调用这个问题。的确,线程完成的工作它们也能完成,但当你所创建的面向对象的程序越来越大,达到一定程度时,你就需要提出类似于自动机(Automaton)的结构。这些便是代表游戏角色的对象——你希望在创建和销毁的时候对游戏主循环没有逻辑副作用。这可以通过C++类并结合多线程编程来实现。
在开始你的第一个多线程程序前,让我们搞清楚一下事实:在单处理器机器上,一次只能运行一个线程。所以天下并没有免费的午餐,但毕竟这是适应软件的方法学,因此确保你是为了简便性和正确性而进行多线程编程。图11-17表示了一个主进程和三个线程同时执行的情况。
图11-17:主进程产生三个子线程
图中的时间表明了不同的线程对处理器的占用时间,单位是毫秒。如你所见,一次只有一个线程在运行,但它们可以打乱顺序运行,并根据优先级高低来确定运行时间。
前戏足够了。让我们看一些代码吧!
取得一个线程
在下面的例子中,你将使用控制台模式程序。再次强调,请正确地编译这些程序。(我已不堪多言,因为我每小时要收到30~60封来自我写的几本书的读者的有关错误使用VC++编译器的电子邮件。难道就没有人读前言吗?)
还有一条告诫是:对于这些例子,你必须使用支持多线程的库(Multithreaded Library)。进入MS DEV Studio的主菜单,选择Project、Setting,在C++表栏的Category: Code Generatrion选项里,将Use Run-time Library设置为Multithreaded。如图11-18所示。此外,确保将优化(Optimization)选项设为off。因为有时候该选项会影响多线程同步代码,所以最好将其关掉以防不测。
图11-18:使用多线程库创建一个控制台应用程序
注意
我有一种似曾相识的感觉。真的是似曾相识吗?还是随机的假象?
如果你没有这种感觉,你就不会知道你没有的是什么,那样倒也无所谓。:)
一切就绪,让我们开始吧。创建一个线程很简单,而防止其被损坏才是困难的部分!Win32 API调用的格式如下:
HANDLE CreateThread(
LPSECURITY_ATTRIBUTES lpThreadAttributes,
// pointer to thread security attributes
DWORD dwStackSize, // initial thread stack size, in bytes
LPTHREAD_START_ROUTINE lpStartAddress,
// pointer to thread function
LPVOID lpParameter, // argument for new thread
DWORD dwCreationFlags, // creation flags
LPDWORD lpThreadId ); // pointer to returned thread identifier
lpThreadAttributes指向一个SECURITY_ATTRIBUTES结构,该结构指定了这个线程的安全属性。如果这个lpThreadAttributes值为NULL,该线程就以默认的安全描述字创建,并且返回的句柄不会被继承。
dwStackSize指定线程堆栈的大小(单位是字节)。如果指定其值为0,堆栈的大小就与进程的主线程相同。堆栈在进程的内存空间内自动分配,并在进程结束时释放。如有必要堆栈大小可以增加。
CreateThread试图分配大小为dwStackSize字节数的内存,并在可用内存不足时返回分配失败消息。
lpStartAddress指向线程所要执行的由应用程序提供的函数,同时这也代表线程的开始地址。该函数接受一个32位的参数并返回一个32位的值。
lpParameter定义一个传递给线程的32位参数值。
lpThreadId指向一个保存线程标识符的32位变量。
如果该函数执行成功,其返回值是指向下一个新线程的句柄。如果该函数执行失败,将返回NULL。若要获得更多的错误信息,调用GetLastError()函数。
The function call might look a bit complex, but it's really not. It just allows a lot of control. You won't use much of its functionality in most cases.
该函数调用看上去有些复杂,但并非如此。它只是提供了更多的控制功能。大多数情况下,你会用到的功能并不多。
当处理完一个线程时,你应当关闭该线程的句柄。换言之,就是让系统知道你不再使用该对象。该功能通过调用函数CloseHandle()实现,该函数使用CreateThread()函数返回的句柄,并将对应该内核对象的引用计数器减1。
对于每个线程都需要这么处理。这不会强行结束该线程,只是用于告诉系统,该线程处于结束运行状态。该线程要么自己结束,要么被通知(使用函数TerminateThread())结束,或者在主线程结束时被操作系统结束。这些我们以后会逐一讨论,现在只需知道这是退出多线程程序之前必须要进行的一个起清除作用的调用。下面是该函数的原型:
BOOL CloseHandle(HANDLE hObject ); // handle to object to close
hObject表示了一个已打开的对象句柄。如果函数调用成功,将返回TRUE;如果失败,返回FALSE。调用函数GetLastError()可得到详细的出错信息。此外,CloseHandle()也适于关闭下列对象的句柄:
? 控制台输入或输出
? 事件文件
? 文件映射
? 互斥体(Mutex)
? 命名管道
? 进程
? 信号量(Semaphore)
? 线程
基本上说来,CloseHandle()使指定对象句柄无效,缩减对象句柄的引用计数,并进行对象存活性测试。当一个对象的最后一个句柄被关闭后,对象就从操作系统中被移除。
警告
一个新线程的句柄在创建时对该线程具有完全的访问权限。如果没有提供安全描述符,该句柄可以被任何需要该线程句柄的函数使用。当提供安全描述符后,所有使用该句柄的访问都要进行权限检查。如果检查结果为拒绝,那么请求的进程将被拒绝使用该句柄访问该线程。
现在来看一些代码,它们代表一个能够被传递给函数CreateThread()的线程:
DWORD WINAPI My_Thread(LPVOID data)
{
// .. do work
// return an exit code at end, whatever is appropriate for your app
return(26);
} // end My_Thread
现在你已具备了创建你的第一个多线程应用程序所需的一切条件。第一个例子将向你展示一个单线程的创建过程。被创建出的从线程打印出数字2,主线程(即主程序)将打印出数字1。DEMO11_5.CPP包括整个程序,如下所示:
// DEMO11_5.CPP - Creates a single thread that prints
// simultaneously while the Primary thread prints.
// INCLUDES
#define WIN32_LEAN_AND_MEAN // make sure win headers
// are included correctly
#include // include the standard windows stuff
#include // include the 32 bit stuff
#include
#include
#include
#include
#include
#include
#include
// DEFINES
// PROTOTYPES //
DWORD WINAPI Printer_Thread(LPVOID data);
// GLOBALS /
// FUNCTIONS ///
DWORD WINAPI Printer_Thread(LPVOID data)
{
// this thread function simply prints out data
// 25 times with a slight delay
for (int index=0; index<25; index++)
{
printf("%d ",data); // output a single character
Sleep(100); // sleep a little to slow things down
} // end for index
// just return the data sent to the thread function
return((DWORD)data);
} // end Printer_Thread
// MAIN ///
void main(void)
{
HANDLE thread_handle; // this is the handle to the thread
DWORD thread_id; // this is the id of the thread
// start with a blank line
printf("/nStarting threads.../n");
// create the thread, IRL we would check for errors
thread_handle = CreateThread(NULL, // default security
0, // default stack
Printer_Thread, // use this thread function
(LPVOID)1, // user data sent to thread
0, // creation flags, 0=start now.
&thread_id); // send id back in this var
// now enter into printing loop, make sure this takes longer than thread,
// so thread finishes first
for (int index=0; index<50; index++)
{
printf("2 ");
Sleep(100);
} // end for index
// at this point the thread should be dead
CloseHandle(thread_handle);
// end with a blank line
printf("/nAll threads terminated./n");
} // end main
输出示例:
Starting threads...
2 1 2 1 2 1 2 1 1 2 2 1 1 2 2 1 1 2 2 1 1 2 2 1 1 2
2 1 1 2 2 1 1 2 2 1 1 2 2 1 1 2 2 1 1 2 2 1 1 2 2 2
2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2
All threads terminated.
正如你所看到的输出结果,每一个线程只运行很短的一段时间,然后系统便切换至另一个正等待运行的线程。在这种情况下,操作系统只是简单地在主线程和从线程间来回切换。
现在让我们试着创建一个多线程程序。你只需对DEMO11_5.CPP略加修改便可实现该功能。你只需多次调用CreateThread()函数,每次调用就创建一个线程。而且每次传递给所创建线程的数据将被打印出来,这样便可以区分你所创建的线程。
DEMO11_6.CPP|EXE包含了修改后的多线程程序,我在下面列出供你参考。注意在这里我用数组储存线程句柄和ID。
// DEMO11_6.CPP - A new version that creates 3
// secondary threads of execution
// INCLUDES /
#define WIN32_LEAN_AND_MEAN // make sure certain headers
// are included correctly
#include // include the standard windows stuff
#include // include the 32 bit stuff
#include
#include
#include
#include
#include
#include
#include
// DEFINES ///
#define MAX_THREADS 3
// PROTOTYPES ///
DWORD WINAPI Printer_Thread(LPVOID data);
// GLOBALS /
// FUNCTIONS //
DWORD WINAPI Printer_Thread(LPVOID data)
{
// this thread function simply prints out data
// 25 times with a slight delay
for (int index=0; index<25; index++)
{
printf("%d ",(int)data+1); // output a single character
Sleep(100); // sleep a little to slow things down
} // end for index
// just return the data sent to the thread function
return((DWORD)data);
} // end Printer_Thread
// MAIN ///
void main(void)
{
HANDLE thread_handle[MAX_THREADS]; // this holds the
// handles to the threads
DWORD thread_id[MAX_THREADS]; // this holds the ids of the threads
// start with a blank line
printf("/nStarting all threads.../n");
// create the thread, IRL we would check for errors
for (int index=0; index {
thread_handle[index] = CreateThread(NULL, // default security
0, // default stack
Printer_Thread, // use this thread function
(LPVOID)index, // user data sent to thread
0, // creation flags, 0=start now.
&thread_id[index]); // send id back in this var
} // end for index
// now enter into printing loop, make sure
// this takes longer than threads,
// so threads finish first, note that primary thread prints 4
for (index=0; index<75; index++)
{
printf("4 ");
Sleep(100);
} // end for index
// at this point the threads should all be dead, so close handles
for (index=0; index CloseHandle(thread_handle[index]);
// end with a blank line
printf("/nAll threads terminated./n");
} // end main
输出示例:
Starting all threads...
4 1 2 3 4 1 2 3 4 1 2 3 1 4 2 3 4 1 2 3 1 4 2 3 4
1 2 3 1 4 2 3 4 1 2 3 1 4 2 3 4 1 2 3 4 1 2 3 4 1
2 3 4 1 2 3 4 1 2 3 4 1 2 3 4 1 2 3 4 1 2 3 4 1 2
3 4 1 2 3 4 1 2 3 4 1 2 3 4 1 2 3 4 1 2 3 4 1 2 3
4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4
4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4
All threads terminated.
哇!是不是很酷?创建多线程是如此容易。如果你头脑反应比较快,说不定你已经听得厌烦,并会质疑:为何每次线程回调都使用同一个函数?这样做的原因在于所有的变量都在堆栈中创建,而且每一个线程都有自己的堆栈。所以每个线程都能正常工作。如图11-19所示。
图11-19:主线程和从线程内存和程序空间分布
图11-19描述了非常重要的一点:终止(Termination)。两个线程都是自行终止运行的,但主线程对此没有控制。此外,主线程也无法判断其他线程是否已运行完毕或已终止(只要它们还能够返回)。
我们需要的是一种在线程间通信和检查各线程的状态的方法。使用函数TerminateThread()结束函数是一种强制的方法,一般不建议读者使用。
线程间的消息传递
让我们看一看主线程是如何控制其所创建的子线程的。例如,主线程可能需要结束所有的子线程。怎样才能实现呢?有以下两种方法可以结束一个线程:
?
向该线程发送消息通知其结束(正确的方法)。
?
使用内核级的调用强行结束该线程(错误的方法)。
尽管在某些情况下也不得不使用错误的方法,但这样是不安全的。因为这种方法只是简单地将线程的部分回收。当该线程需要执行清理操作时,将无法进行。这会造成内存和资源信息的泄漏。所以在使用这种方法时要慎之又慎。图11-20示意了使用这两种方法结束线程的过程。
图11-20:线程终止方法
首先来看一看如何使用TerminateThread()函数,然后看一个有关向线程发送结束消息以通知该线程执行结束操作的例子。
BOOL TerminateThread(HANDLE hThread, // handle to the thread
DWORD dwExitCode ); // exit code for the thread
hThread指明了要结束的线程。该句柄必须能够访问THREAD_TERMINATE。
dwExitCode定义线程的退出代码。使用GetExitCodeThread()函数以获得线程的退出值。
如果函数调用成功,返回值为TRUE;否则返回值为FALSE。调用函数GetLastError()可得到详细的出错信息。
TerminateThread()函数用于退出一个线程。当调用该函数时,目标线程就停止执行任何用户代码,并且其初始堆栈不会被释放。而连接到该线程的动态连接库并不会收到该线程正在结束的通知,这是不好的地方之一。:)
TerminateThread()函数的用法非常简单,只需简单地调用被结束线程的句柄,并重载返回代码,此后该线程就不复存在了。可是不要误解我的意思,毕竟如果此函数没用的话就不会存在了。因此,用它的时候要确保你已经考虑周详并明白该函数可能带来的后果。
下面介绍通过由从线程监控的全局变量来进行消息传递的线程终止方法。当从线程检测到该全局终止标志被设置时,从线程便全部结束。可是主线程如何知道所有的从线程结束了呢?完成该功能的一个方法是设置另一个在线程终止的时候递减的全局变量——也就是某种引用计数器。
该计数器可以被主线程监测,当它为0时说明所有的从线程都已结束,主线程此刻可以安全地继续工作并关闭线程的句柄。下面给出一个完整的消息传递系统例子,之后,我们离真正的编程就不远了。DEMO11_7.CPP|EXE演示了全局消息传递的过程,如下所示:
// DEMO11_7.CPP - An example of global message passing to control
// termination of threads.
// INCLUDES ///
#define WIN32_LEAN_AND_MEAN // make sure certain headers
// are included correctly
#include // include the standard windows stuff
#include // include the 32 bit stuff
#include
#include
#include
#include
#include
#include
#include
// DEFINES //
#define MAX_THREADS 3
// PROTOTYPES //
DWORD WINAPI Printer_Thread(LPVOID data);
// GLOBALS /
int terminate_threads = 0; // global message flag to terminate
int active_threads = 0; // number of active threads
// FUNCTIONS
DWORD WINAPI Printer_Thread(LPVOID data)
{
// this thread function simply prints out data until it is told to terminate
for(;;)
{
printf("%d ",(int)data+1); // output a single character
Sleep(100); // sleep a little to slow things down
// test for termination message
if (terminate_threads)
break;
} // end for index
// decrement number of active threads
if (active_threads > 0)
active_threads--;
// just return the data sent to the thread function
return((DWORD)data);
} // end Printer_Thread
// MAIN //
void main(void)
{
HANDLE thread_handle[MAX_THREADS]; // this holds the
// handles to the threads
DWORD thread_id[MAX_THREADS]; // this holds the ids of the threads
// start with a blank line
printf("/nStarting Threads.../n");
// create the thread, IRL we would check for errors
for (int index=0; index < MAX_THREADS; index++)
{
thread_handle[index] = CreateThread(NULL, // default security
0, // default stack
Printer_Thread, // use this thread function
(LPVOID)index, // user data sent to thread
0, // creation flags, 0=start now.
&thread_id[index]);// send id back in this var
// increment number of active threads
active_threads++;
} // end for index
// now enter into printing loop, make sure this
// takes longer than threads,
// so threads finish first, note that primary thread prints 4
for (index=0; index<25; index++)
{
printf("4 ");
Sleep(100);
} // end for index
// at this point all the threads are still running,
// now if the keyboard is hit
// then a message will be sent to terminate all the
// threads and this thread
// will wait for all of the threads to message in
while(!kbhit());
// get that char
getch();
// set global termination flag
terminate_threads = 1;
// wait for all threads to terminate,
// when all are terminated active_threads==0
while(active_threads);
// at this point the threads should all be dead, so close handles
for (index=0; index < MAX_THREADS; index++)
CloseHandle(thread_handle[index]);
// end with a blank line
printf("/nAll threads terminated./n");
} // end main
输出示例:
Starting Threads...
4 1 2 3 4 2 1 3 4 3 1 2 4 2 1 3 4 3 1 2 4 2 1 3 4 2
3 1 4 2 1 3 4 2 3 1 4 2 3 1 4 2 3 1 4 2 3 1 4 2 3 1
4 2 3 1 4 2 3 1 4 2 3 1 4 2 3 1 4 2 3 1 4 2 3 1 4 2
3 1 4 2 3 1 4 2 3 1 4 2 3 1 4 2 3 1 4 2 3 1 2 3 1 3 2
1 1 2 3 3 2 1 1 2 3 3 2 1 1 2 3 3 2 1 1 2 3 3 2 1 1 2
3 3 2 1 2 3 1 3 2 1 2 3 1 3 2 1 2 3 1 3 2 1 2 3 1 3 2
1 3 1 2 3 2 1 3 1 2 3 2 1
All threads terminated.
如输出所示,当用户敲击一个键时,所有的线程被结束,随后主线程也被结束。这种方法有两个问题。第一个问题比较不明显。下面是它的具体案例,多看几遍你便会发现问题:
1. 假定只剩一个从线程没有关闭。
2. 假定最后一个线程对处理器拥有控制,并递减跟踪激活线程数的全局变量值。
3. 就在这一刹那,进程切换到主线程。主线程监测全局变量并认为所有的线程都已结束,然而最后一个线程却还没有返回!
在大多数情况下,这不是一个问题,但是如果递减代码和返回代码之间还有代码,便会出现这个问题。所以我们需要一个函数来询问线程是否已结束。很多情况下,这是非常有帮助的。参考一下Wait*()系列函数,对编程会大有益处。
第二个问题是当你创建了一个忙循环(Busy Loop)或轮询的循环。在Win16/DOS系统下,该循环会良好地执行,但在Win32下,就很不好。在一个封闭的循环中,等待一个变量,会给多任务内核带来繁重的负担并严重占用CPU资源。
可以使用Windows附带的SYSMON.EXE(Windows 95/98/ME/XP的附件中)、PERFMON.EXE(Windows NT/2000)或类似的第三方CPU占用率测试工具来进行测定。这些工具软件会有助于你明白线程的运行状态和处理器的占用率。接下来,我们看看Wait*()类函数将如何帮助我们确定一个线程是否结束。
等待合适时机
Get ready for the most confusing explanation you've ever heard… but it's not my fault, really! Whenever any thread terminates, it becomes signaled to the kernel, and when it is running, it is unsignaled. Whatever that means. And what is the price of plastic zippers tomorrow? You don't care! But what you do care about is how to test for the signaling.
我们知道,任何线程结束时都会向内核发出信号(Signaled),而它们运行时不会发出信号(Unsignaled)。需要了解的是如何监测这些信号。
可以使用Wait*()函数族来实现事件监测,该类函数可以实现单信号或多信号的侦测。此外,你可以调用其中一个Wait*()函数来等待,直到信号产生。这样做就可以避免使用忙循环。在绝大多数情况下,这样做要远优于轮询全局变量的方法。图11-21示意了Wait*()函数的工作机制以及与运行程序和OS内核间的关系。
图11-21:使用Wait*()函数的信号时间关系
需要使用的两个函数分别是WaitForSingleObject()和WaitForMultipleObjects()。这两个函数分别用于单信号和多信号侦测。它们的定义如下:
DWORD WaitForSingleObject(HANDLE hHandle, // handle of object to wait for
DWORD dwMilliseconds ); // time-out interval in milliseconds
hHandle用于确定对象。
dwMilliseconds定义超时时间,以毫秒为单位。如果该段时间间隔已经过去,即使侦测对象无信号也要返回。如果dwMilliseconds值为0,函数就立刻测读对象的状态并返回。如果其值为无限大,则函数永不超时。
If the function succeeds, the return value indicates the event that caused the function to return. If the function fails, the return value is WAIT_FAILED. To get extended error information, call GetLastError().
如果该函数执行成功,其返回值包含返回的条件状态。如果该函数执行失败,返回值是WAIT_FAILED。调用函数GetLastError()可以获得详细的出错信息。
该函数的成功返回值有以下几种:
? WAIT_ABANDONED——所指定的对象是一个互斥对象,该对象在所属线程结束前不能被线程释放。互斥对象的所有权被授予调用线程,互斥对象被设置为无信号。
? WAIT_OBJECT_0——指定对象的状态是有信号的。
? WAIT_TIMEOUT——超时时间已过,对象的状态是无信号的。
一般说来,WaitForSingleObject()函数检查指定对象的当前状态。如果对象无信号,调用线程就进入一种很有效率的等待状态。在此期间,该线程只占用极少的CPU时间,直到它等待的条件之一得到满足才结束等待。下面是多信号侦测函数,主要用于终止多个线程:
DWORD WaitForMultipleObjects(DWORD nCount, // number of handles
// in handle array
CONST HANDLE *lpHandles, // address of object-handle array
BOOL bWaitAll, // wait flag
DWORD dwMilliseconds ); // time-out interval in milliseconds
nCount定义lpHandles所指向的对象句柄数组中元素的数目。对象句柄的最大数目是MAXIMUM_WAIT_OBJECTS。
lpHandles指向对象句柄的数组。该数组包含不同类型对象的句柄。注意对于Windows NT:句柄必须有SYNCHRONIZE访问。
bWaitAll定义等待类型。如果值为TRUE,则当lpHandles数组中所有对象同时都有信号时返回。如果值为FALSE,那么任一对象有信号就返回。对于后一种情况,返回值指明了引起函数返回的的对象。
dwMilliseconds定义超时时间(单位为毫秒)。即使bWaitAll参数定义的条件没有得到满足,只要超时间隔已过,该函数也要返回。如果dwMilliseconds的值为0,函数就立刻侦测指定对象的状态并返回。如果其值为无穷,那么函数永不超时。
如果该函数执行成功,其返回值表示引起函数返回的事件。如果该函数执行失败,则返回WAIT_FAILED。调用函数GetLastError()可以获得详细的出错信息。函数返回值主要有以下几种:
?
WAIT_OBJECT_0 到(WAIT_OBJECT_0 + nCount - 1)——如果bWaitAll值为TRUE,则该返回值表明所有指定对象都有信号。如果其值为FALSE,则返回值减去WAIT_OBJECT_0,这差给出满足等待的对象的lpHandles数组索引。如果在调用过程中,检测到一个以上的对象有信号时,取有信号对象数组索引中的最小值。
?
WAIT_ABANDONED_0到(WAIT_ABANDONED_0 + nCount - 1)——如果bWaitAll值为TRUE,则该返回值表明所有指定对象都有信号,而且至少有一个对象是被废弃的互斥对象。如果bWaitAll值为FALSE,则返回值减去WAIT_ABANDONED_0,这差给出满足等待的废弃互斥对象的lpHandles数组索引。
?
WAIT_TIMEOUT——超时间隔已过,但不满足由bWaitAll参数指定的条件。
?
WaitForMultipleObjects()函数确定是否满足退出的等待条件。如果等待条件不满足,调用线程就进入一个有效率的等待状态,直到等待条件满足。在此状态下,该线程只消耗很少的系统资源。
使用信令来同步线程
这些解释的技术性很强,所以需要举例来说明这些函数的用法,只要对之前例子中的代码稍加修改即可。在接下来的版本中,你将移去全局结束信号标志,并创建一个简单调用函数WaitForSingleObject()的主循环。
移去全局结束消息只是为了使程序变得简单些。不可否认,它仍然是通知线程结束的最好方法。但由于处于忙循环中,因此不是测试线程自身是否已结束的最好方法。
这也是使用WaitForSingleObject()调用的原因所在。该调用处于一个占用较少CPU时间的、虚拟的等待循环中。而且因为函数WaitForSingleObject()只能等待一个信号,即只能用于一个线程的结束,所以这个例子中只有一个从线程。
稍后,我们将重写程序。新程序将包含三个线程,并使用WaitForMultipleObjects()来等待它们全部结束。DEMO11_8.CPP|EXE就使用了WaitForSingleObject()来结束单线程,并创建了另外一个线程,其代码如下:
// DEMO11_8.CPP - A single threaded example of
// WaitForSingleObject(...).
// INCLUDES //
#define WIN32_LEAN_AND_MEAN // make sure certain
// headers are included correctly
#include // include the standard windows stuff
#include // include the 32 bit stuff
#include
#include
#include
#include
#include
#include
#include
// DEFINES //
// PROTOTYPES //
DWORD WINAPI Printer_Thread(LPVOID data);
// GLOBALS /
// FUNCTIONS //
DWORD WINAPI Printer_Thread(LPVOID data)
{ // this thread function simply prints out data 50
// times with a slight delay
for (int index=0; index<50; index++)
{
printf("%d ",data); // output a single character
Sleep(100); // sleep a little to slow things down
} // end for index
// just return the data sent to the thread function
return((DWORD)data);
} // end Printer_Thread
// MAIN ///
void main(void)
{
HANDLE thread_handle; // this is the handle to the thread
DWORD thread_id; // this is the id of the thread
// start with a blank line
printf("/nStarting threads.../n");
// create the thread, IRL we would check for errors
thread_handle = CreateThread(NULL, // default security
0, // default stack
Printer_Thread, // use this thread function
(LPVOID)1, // user data sent to thread
0, // creation flags, 0=start now.
&thread_id); // send id back in this var
// now enter into printing loop, make sure
// this is shorter than the thread,
// so thread finishes last
for (int index=0; index<25; index++)
{
printf("2 ");
Sleep(100);
} // end for index
// note that this print statement may get
// interspliced with the output of the
// thread, very key!
printf("/nWaiting for thread to terminate/n");
// at this point the secondary thread so still be working,
// now we will wait for it
WaitForSingleObject(thread_handle, INFINITE);
// at this point the thread should be dead
CloseHandle(thread_handle);
// end with a blank line
printf("/nAll threads terminated./n");
} // end main
输出示例:
Starting threads...
2 1 2 1 2 1 1 2 2 1 1 2 2 1 1 2 2 1 1 2 2 1
1 2 2 1 1 2 2 1 1 2 2 1 1 2 2 1 1 2 2 1 1 2 2 1 1 2 2 1 1
Waiting for thread to terminate
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
All threads terminated.
这个程序很简单。通常在创建从线程后就进入打印循环。当这些终止时,调用函数WaitForSingleObject()。如果主线程还有其他工作要做,则继续进行。但在本例中,主线程没有其他任务,因而直接进入等待状态。如果你在运行该程序之前运行了SYSMON.EXE,你会看见进入等待状态后CPU的占用率极低,而在忙循环中CPU被占用得相当厉害。
在这里,函数WaitForSingleObject()有一个使用技巧。假如想知道一个线程在调用该函数时的状态,可以通过用NULL调用WaitForSingleObject()函数来实现,源代码如下:
//...code
DWORD state = WaitForSingleObject(thread_handle, 0); // get the status
// test the status
if (state==WAIT_OBJECT_0) { // thread is signaled, i.e. terminated }
else
if (state==WAIT_TIMEOUT) { // thread is still running }
//...code
简单之至,这是检测一个特定的线程是否已结束的绝妙方法。结合这一方法使用全局终止消息是一种非常可靠的终止线程的方法。同时该方法是在实时循环中检测某个线程是否已终止,而无须进入等待状态的好方法。
等待多个对象
问题现在几乎都解决了。Wait*()类函数的最后一个函数就是一个用于等待多个对象或线程信号的函数。我们现在试着编写使用该函数的程序。我们所要做的就是创建一个线程数组,然后将该句柄数组与若干参数一起传递给WaitForMultipleObjects()函数。
当该函数返回时,如果一切正常,那么所有的线程应该都已终止。DEMO11_9.CPP|EXE与DEMO11_8.CPP|EXE相似,只不过它是创建多线程,然后主线程等待所有其他线程终止而已。在这里,不使用全局终止标志,因为你已知道如何实现这一功能。每个从线程运行几个周期后就终止。DEMO11_9.CPP的源代码如下所示:
// DEMO11_9.CPP -An example use of
// WaitForMultipleObjects(...)
// INCLUDES ///
#define WIN32_LEAN_AND_MEAN // make sure certain headers
// are included correctly
#include // include the standard windows stuff
#include // include the 32 bit stuff
#include
#include
#include
#include
#include
#include
#include
// DEFINES ///
#define MAX_THREADS 3
// PROTOTYPES /
DWORD WINAPI Printer_Thread(LPVOID data);
// GLOBALS
// FUNCTIONS //
DWORD WINAPI Printer_Thread(LPVOID data)
{
// this thread function simply prints out data 50
// times with a slight delay
for (int index=0; index<50; index++)
{
printf("%d ",(int)data+1); // output a single character
Sleep(100); // sleep a little to slow things down
} // end for index
// just return the data sent to the thread function
return((DWORD)data);
} // end Printer_Thread
// MAIN
void main(void)
{
HANDLE thread_handle[MAX_THREADS]; // this holds the
// handles to the threads
DWORD thread_id[MAX_THREADS]; // this holds the ids of the threads
// start with a blank line
printf("/nStarting all threads.../n");
// create the thread, IRL we would check for errors
for (int index=0; index {
thread_handle[index] = CreateThread(NULL, // default security
0, // default stack
Printer_Thread,// use this thread function
(LPVOID)index, // user data sent to thread
0, // creation flags, 0=start now.
&thread_id[index]); // send id back in this var
} // end for index
// now enter into printing loop,
// make sure this takes less time than the threads
// so it finishes first
for (index=0; index<25; index++)
{
printf("4 ");
Sleep(100);
} // end for index
// now wait for all the threads to signal termination
WaitForMultipleObjects(MAX_THREADS, // number of threads to wait for
thread_handle, // handles to threads
TRUE, // wait for all?
INFINITE); // time to wait,INFINITE = forever
// at this point the threads should all be dead, so close handles
for (index=0; index CloseHandle(thread_handle[index]);
// end with a blank line
printf("/nAll threads terminated./n");
} // end main
输出示例:
Starting all threads...
4 1 2 3 4 1 2 3 1 4 2 3 2 4 1 3 1 4 2 3 2 4 1 3
1 4 2 3 2 4 1 3 1 4 2 3 2 4 1 3 1 4 2 3 2 4 1 3
1 4 2 3 2 4 1 3 1 4 2 3 2 4 1 3 1 4 2 3 2 4 1 3
1 4 2 3 2 4 1 3 1 4 2 3 2 4 1 3 1 4 2 3 2 4 1 3
1 4 2 3 2 1 3 2 1 3 2 1 3 2 1 3 2 1 3 2 1 3 2 1
3 2 1 3 2 1 3 2 1 3 2 1 3 2 1 3 2 1 3 2 1 3 2 1
3 2 1 3 2 1 3 2 1 3 2 1 3 2 1 3 2 1 3 2 1 3 2 1 3 2 1 3 2 1 3
All threads terminated.
输出正如你所预料的那样。所有线程和主线程一样都进行了打印输出工作,但当主线程的循环完成时,次线程将继续进行,直到它们全部完成为止。由于函数WaitForMultipleObjects()的阻塞作用,只当所有线程全部结束后,主线程才终止返回。
多线程和DirectX
现在我们对多线程有了一定的了解。下一个问题便是如何将其运用于游戏程序和DirectX编程。放手去做——这里有你需要的一切。当然,必须确保编译时使用多线程库而不是单线程库。并且,在处理大量的DirectX资源时,我们还会遇上许多临界区(Critical Section)的问题。
对于资源要有一个全局规划,以防止一个以上的线程访问同一个资源时出现程序崩溃。比如,假定一个线程锁定了一个表面,而另一个运行中的线程试图锁定同一个表面。这样就会引起问题。这类问题可以使用信号量(Sempahore)、互斥体(Mutex)和临界区来解决。在这里我不能逐一详细探讨。但你可以查阅相应的资料,如由Addsion Wesley出版、Jim Beveridge和Robert Weiner合著的《Multithreading Applications in Win32》。这是关于这方面内容最好的参考书。
为实现这类资源管理程序并正确地共享线程,我们需要创建一个变量来跟踪其他使用该资源的线程。任何需要使用该资源的线程都必须检测该变量,之后才能使用它。当然,这依然是个问题,除非这个变量能够被检测或独立(Atomically)地修改,因为你可能正修改一个变量进行到一半而其他线程恰在这时获得控制权。
可以将这类变量设置为volatile类型以将其问题发生概率最小化,这样便告知编译器不要为其进行内存拷贝。然而,最终你还不得不使用信号量(Semaphores,一个简单的类似于全局变量的计数器,但却是以不能被打断的基本汇编代码形式存在)、互斥体(Mutex,只允许一个线程访问临界区,是二进制的信号量)、临界区(Critical Section,指定编译器在编译Win32调用时一次只能允许一个线程)等等。另一方面,如果每一个线程的功能相对比较独立,也就不必过多考虑这些。
DEMO11_10.CPP|EXE是一个应用多线程编程的DirectX实例(其16位版本是DEMO11_10_16B.CPP|EXE),读者可以检验一下该程序。该程序创建了许多外星BOB(Blitter Object),并使它们围绕主线运动。此外该程序还创建了另外一个线程以动态改变这些BOB的颜色。这是一个非常简单、安全的多线程程序范例。最后要确保将该程序连上所有DirectX .LIB文件。
但是,如果有许多线程都调用同一函数,就会产生可重入性(Reentrancy)的问题。需要重入的函数必须要有状态信息,而且不能使用可能会被具有抢占优先权的线程进出程序时破坏的全局变量。
此外,如果使用线程来使DirectX对象自己动起来,表面事故、计时及同步过程很可能会出错。因此建议读者严格限制使用线程来处理那些在很大程度上是独立的、只存在于它们自己的“状态空间”中的并且不需要以精确频率运行的对象。
高级多线程编程
好了!本章到此也告一段落。因为接下来我们只能探讨竞争条件、死锁、临界区、互斥体、信号量以及许多令人头疼的问题。澄清所有这些问题(除了最后一个)都会有助于读者编写无错的多线程程序。当然,即使对此一无所知,读者仍然能够依据常识编写出基本安全的多线程程序,记住任何线程都可能会随时被其他线程打断这个道理。注意你编写的线程是如何访问共享数据结构的。
尽可能以独立且自动的方式进行这些操作。确保这样的事情不会发生:一个线程修改变量,而另一个线程错误地使用了正被修改到一半的变量。同时,除本章提及的函数调用外,还有几个基本函数调用没有提及,如ExitThread()和GetThreadExitCode(),但这几个函数相对比较简单易于理解,并可以在你的API参考书中查到它们。
总结
本章内容读来比较轻松,没有太多的技术术语,只是一顿丰富的知识大餐。我形容它为大餐,呃,大概是因为我在写作过程中吃了太多Power Bar速食条了。言归正传,本章中我们接触了许多基础知识:如数据结构、内存管理、递归、分析、定点数运算和多线程。
其中有些内容乍看与游戏似乎关系不大,但确实相关。要制作一个游戏,我们必须了解编程的每一方面——因为游戏的确是如此复杂!现在本章告一段落,我要出门去租《2001: A Space Odyssey》回来看了,因为下一章我们将探讨人工智能……