1.1:简单动画
游戏离不开动画。我们考虑最简单的情况:将一个角色从一个位置移动到另外一个位置。这个行为表述给电脑就是,将一个surface不断的blit(),从起始位置的坐标,移动到结束位置的坐标。移动速度取决于每次blit()的坐标差和blit()的时间间隔(v = ds/dt )。
我们来设计一个函数实现这个简单的动画。我们需要的数据有:起始坐标(int beginX, int beginY),结束坐标(int endX, int endY),以及作为SDL基础的ScreenSurface窗口(const ScreenSurface& screen)。一般的考虑是,将这5个数据以参数的方式传入函数;但是一种更加通用一点的方式是,将这5种数据合并成一个结构,这样函数的参数形式会更加的统一,这正是触发多线程的函数所需要的。在SDL中,我们通过函数:
SDL_Thread
*
SDL_CreateThread(
int
(
*
fn)(
void
*
),
void
*
data);
触发多线程,其中所需要的函数指针形式为:
typedef
int
(
*
fn)(
void
*
);
而void*类型的data就是函数(*fn)()需要的的数据。我们可以将任意的结构体指针,转化为void*,作为这个函数的第二个参数需要。
因此,我们可以为我们需要的动画函数定义一个结构作为传递所有数据的载体:
struct
AmnArg
{
int
beginX;
int
beginY;
int
endX;
int
endY;
const
ScreenSurface
&
screen;
AmnArg(
int
begin_x,
int
begin_y,
int
end_x,
int
end_y,
const
ScreenSurface
&
_screen): beginX(begin_x), beginY(begin_y), endX(end_x), endY(end_y), screen(_screen){}
};
这样,我们可以将AmnArg对象的指针传递给动画函数——考虑到多线程函数的需要,我们再曲折一点:先将AmnArg*转换成void*传递给函数,在函数内部再将其转换回来以供调用。
int
amn(
void
*
data)
{
AmnArg
*
pData
=
(AmnArg
*
)data;
PictureSurface stand(
"
./images/am01.png
"
, pData
->
screen);
stand.colorKey();
PictureSurface bg(
"
./images/background.png
"
, pData
->
screen);
const
int
SPEED_CTRL
=
300
;
int
speedX
=
(pData
->
endX
-
pData
->
beginX)
/
SPEED_CTRL;
int
speedY
=
(pData
->
endY
-
pData
->
beginY)
/
SPEED_CTRL;
for
(
int
i
=
0
; i
<
SPEED_CTRL; i
++
){
pData
->
beginX
+=
speedX;
pData
->
beginY
+=
speedY;
bg.blit(pData
->
beginX, pData
->
beginY, pData
->
beginX, pData
->
beginY, stand.point()
->
w, stand.point()
->
h,
2
,
2
);
stand.blit(pData
->
beginX, pData
->
beginY);
pData
->
screen.flip();
}
return
0
;
}
注意:我们这里仅仅设定了每次blit()的位移差(ds)而没有设定时间差(dt)。这并不意味着dt == 0,事实上,电脑处理数据是需要时间的,包括运算和显示。我们这里事实上将dt的设定交给了电脑,也就是说,让电脑以其最快的速度来完成。为什么要这么做呢?这是为了演示多线程的一个现象,卖个关子,后面解释。:)
1.2:动画函数在主程序中的调用
#include
"
SurfaceClass.hpp
"
#include
"
amn.hpp
"
int
main(
int
argc ,
char
*
argv[])
{
//
Create a SDL screen.
const
int
SCREEN_WIDTH
=
640
;
const
int
SCREEN_HEIGHT
=
480
;
const
Uint32 SCREEN_FLAGS
=
0
;
//
SDL_FULLSCREEN | SDL_DOUBLEBUF | SDL_HWSURFACE
const
std::
string
WINDOW_NAME
=
"
Amn Test
"
;
ScreenSurface screen(SCREEN_WIDTH, SCREEN_HEIGHT, WINDOW_NAME,
0
, SCREEN_FLAGS);
PictureSurface bg(
"
./images/background.png
"
, screen);
bg.blit(
0
);
screen.flip();
AmnArg test1(
0
,
250
,
600
,
250
, screen);
amn((
void
*
)
&
test1);
SDL_Event gameEvent;
bool
gameOver
=
false
;
while
( gameOver
==
false
){
while
( SDL_PollEvent(
&
gameEvent)
!=
0
){
if
( gameEvent.type
==
SDL_QUIT ){
gameOver
=
true
;
}
if
( gameEvent.type
==
SDL_KEYDOWN ){
if
( gameEvent.key.keysym.sym
==
SDLK_ESCAPE ){
gameOver
=
true
;
}
}
screen.flip();
}
}
return
0
;
}
当这个程序运行的时候,我们会发现一些很明显的问题:
1、图片移动的时候,界面不接受任何信息。这是因为必须把amn()执行完毕才会运行到有事件响应的事件轮询循环。
2、如果我们需要另外一张图片移动起来,我们唯一能做的事情,是修改amn()函数,而不是把amn()以不同的参数调用两次——如果以不同的参数调用两次,那么移动总是有先后的——是不可能完成“同时”移动的。
1.3:创建线程
如果要将这个程序从主线程(主进程)调用函数修改为通过新创建的线程调用函数,只需要做很小的修改,即将amn((void*)&test1);修改为:
SDL_Thread
*
thread1
=
SDL_CreateThread(amn, (
void
*
)
&
test1);
然后在return 0;之前加入清理线程的语句:
SDL_KillThread(thread1);
这样,程序在执行动画的同时,事件轮询就已经开始,我们可以随时结束程序,SDL界面也不会出现不响应的情况。
2.1:竞争条件(Race Conditions)
我们在前面将一个普通函数调用转换成了用线程调用,这意味着我们可以“同时”调用两个以上的线程。例如,我们希望在屏幕的另外一个位置也播放这段简单的动画,我们只需要添加一个线程的调用就可以了。
int
main(
int
argc ,
char
*
argv[])
{
//
Create a SDL screen.
const
int
SCREEN_WIDTH
=
640
;
const
int
SCREEN_HEIGHT
=
480
;
const
Uint32 SCREEN_FLAGS
=
0
;
//
SDL_FULLSCREEN | SDL_DOUBLEBUF | SDL_HWSURFACE
const
std::
string
WINDOW_NAME
=
"
Amn Test
"
;
ScreenSurface screen(SCREEN_WIDTH, SCREEN_HEIGHT, WINDOW_NAME,
0
, SCREEN_FLAGS);
PictureSurface bg(
"
./images/background.png
"
, screen);
bg.blit(
0
);
screen.flip();
AmnArg test1(
0
,
250
,
600
,
250
, screen);
SDL_Thread
*
thread1
=
SDL_CreateThread(amn, (
void
*
)
&
test1);
AmnArg test2(
0
,
0
,
600
,
0
, screen);
SDL_Thread
*
thread2
=
SDL_CreateThread(amn, (
void
*
)
&
test2);
SDL_Event gameEvent;
bool
gameOver
=
false
;
while
( gameOver
==
false
){
while
( SDL_PollEvent(
&
gameEvent)
!=
0
){
if
( gameEvent.type
==
SDL_QUIT ){
gameOver
=
true
;
}
if
( gameEvent.type
==
SDL_KEYDOWN ){
if
( gameEvent.key.keysym.sym
==
SDLK_ESCAPE ){
gameOver
=
true
;
}
}
screen.flip();
}
}
SDL_KillThread(thread1);
SDL_KillThread(thread2);
return
0
;
}
这段程序看起来似乎没有什么问题,但是运行的时候,不可预知的情况出现了:理论上我们几乎同时调用了两个线程,动画似乎应该是同步播放的,但是实际上,两段动画的播放并不同步,并且每次执行的效果都不一样——有时候上面的图片移动快,有时候下面的图片移动快,并且速度不均匀。
这就是典型的race conditions的表现。还记得我说过没有定义dt吗,我们让电脑以其所能达到的最快速度决定dt,换句话说,我们每一个线程都试图“咬死”CPU的运算,当然,在实际中多任务的OS会帮助CPU分配任务,但是如何分配却是不确定的,因为OS并不知道哪些任务需要优先执行,所以,两个线程实际上在竞争电脑的性能资源,产生的结果就是不确定的。
2.2:松开“死咬”的CPU
void
SDL_Delay(Uint32 ms);
解决race conditions的方法就是给CPU足够的时间“休息”,而这正好也是我们自己定义dt所需要的。SDL_Delay()在这个时候就显得意义重大了。当今电脑的运算速度非常非常快,以至于哪怕我们仅仅给电脑0.01秒的时间“休息”(每次循环中),电脑都会显得很轻松了。
int
amn(
void
*
data)
{
AmnArg
*
pData
=
(AmnArg
*
)data;
PictureSurface stand(
"
./images/am01.png
"
, pData
->
screen);
stand.colorKey();
PictureSurface bg(
"
./images/background.png
"
, pData
->
screen);
const
int
SPEED_CTRL
=
300
;
int
speedX
=
(pData
->
endX
-
pData
->
beginX)
/
SPEED_CTRL;
int
speedY
=
(pData
->
endY
-
pData
->
beginY)
/
SPEED_CTRL;
for
(
int
i
=
0
; i
<
SPEED_CTRL; i
++
){
pData
->
beginX
+=
speedX;
pData
->
beginY
+=
speedY;
bg.blit(pData
->
beginX, pData
->
beginY, pData
->
beginX, pData
->
beginY, stand.point()
->
w, stand.point()
->
h,
2
,
2
);
stand.blit(pData
->
beginX, pData
->
beginY);
pData
->
screen.flip();
SDL_Delay(
10
);
}
return
0
;
}
说到这里,我们不得不提及之前一直所忽略的一个问题:我们之前凡是涉及循环等待事件轮询的程序总是占用100%的CPU,这并不是因为我们真正用到了 100%的CPU性能,而是我们让CPU陷入了“空等”(Busy Waiting)的尴尬境地。轮询事件得到响应相对于循环等待来说,是发生得非常缓慢的事情,我们在循环中,哪怕是让电脑休息0.01秒,事情都会发生本质性的改变:
while
( gameOver
==
false
){
while
( SDL_PollEvent(
&
gameEvent)
!=
0
){
if
( gameEvent.type
==
SDL_QUIT ){
gameOver
=
true
;
}
if
( gameEvent.type
==
SDL_KEYDOWN ){
if
( gameEvent.key.keysym.sym
==
SDLK_ESCAPE ){
gameOver
=
true
;
}
}
screen.flip();
}
SDL_Delay(
10
);
}
当我们重新运行新程序的时候,我们可以看到程序对CPU的占用从100%骤降到了0%!这当然并不意味着程序就用不上CPU了,而是说,这些运算对于我们的CPU来说,实在是小菜一碟了,或者从数据上说,处理这些运算的时间与0.01秒来比较,都几乎可以忽略不计!
2.3:GUI线程与worker线程
我们的另外一项试验是将事件轮询放到动画线程中,程序就不多写了,大家可以自己试下。我直接说结论:动画线程中无法响应事件轮询。
一般提倡的模式,是将GUI事件都写在主线程中,而将纯粹的运算才写到由主线程创建的线程中,后者也就是所谓的worker线程。从另外一个概念看,只有主线程控制着“当前窗口”,其它线程也许在后台,也许虽然也是在前台但是并非是我们可见的,所以,轮询事件找不到接口。
对于抛出的线程与主线程之间的通讯,我们可以通过他们共享的数据来进行控制,所以,尽管事件轮询不能直接影响worker线程,但是我们仍然是可以通过主线程进行间接影响的。