EGE专栏:EGE专栏
动画顾名思义,就是动起来的画面。画面为什么会动起来了呢?在回答这个问题之前,我们先引入一些概念。
视觉暂留
人眼在观察景物时,光信号传入大脑神经,需经过一段短暂的时间,光的作用结束后,视觉形象并不立即消失,这种残留的视觉称“后像”,视觉的这一现象则被称为 “视觉暂留”。 视觉暂留现象大约会持续0.1~0.4秒的时间(下面以0.1秒来说)。也就是说,间隔0.1秒内显示的图像会重叠在一起,使得当画面切换后,上次的画面影像依然有所停留,和这次的画面混合在一起。当然,这个视觉暂留会随时间衰减,并不是在这0.1秒时间里图像都是清晰的,而是会不断衰减,直至消失。 下面是视觉暂留的现象,由于视觉暂留,两个不同的图像混合在了一起。。。
似动现象
似动知觉是指在一定的条件下人们把客观静止的物体看成是运动的,或把客观上不连续的位移看似是连续的运动。这是一种心理学上的现象。似动知觉的产生是有条件的,需要一定的时间间隔和空间间隔。 gif动图和电影动画之类的都是利用似动知觉而让人感觉是在连续变化的,不同画面之间的连续与微小差别,满足了似动知觉的条件。
动画是按一定时间间隔显示静止的图像的,但是由于似动知觉,虽然是静止的图像,但由于间隔时间短且变化小,视觉系统便感受到的是连续运动。 视觉暂留现象并非是感知到画面运动的原因,而是保证了画面的质量,由于视觉暂留现象,即使中间有很短的时间内图像消失,也能看到画面连续,空缺部分被视觉暂留所补充。
动画是利用似动现象,通过以一定的时间间隔连续播放静止的画面给视觉看起来像是连续变化的画面。帧就是影像动画中最小单位的单幅影像画面,一帧就是一副静止的画面。 如下面的一个Gif动图,总帧数为25,通过以30ms一帧的速度来切换画面,看起来就是一个连续的画面。
帧数,即帧的数量。上面的动画帧数就是25,由25帧组成。 帧率,即帧的速率,一般用 每秒帧数(FPS) 表示。FPS,即 Frame per Second的缩写,是每秒生成画面帧的数量。 帧率的大小会影响动画的流畅度,由于人眼视觉暂留现象,,使得帧率越大看起来越流畅。 下面是以不同的帧率显示的蹦床,当然,三张图开始时间有点错位,没有统一,但是完成一整个动作的时间是相等的。可以看出,当帧率为较低的15FPS时,觉得画面有些卡顿和不连贯,达到 60FPS 时便可以看到流畅的动画
电影的标准帧率为24FPS,电视的标准帧率为25FPS,这个帧率已经使得画面流畅,现在的电影大多使用24FPS的帧率。当然,为了更流畅,有些高清高画质视频使用了50FPS或60FPS等高帧率。 游戏中的帧率一般要达到60FPS以上才会感到流畅。 电影使用24FPS便可以感到流畅,足以满足观看需求,而游戏却感到卡顿是因为电影使用了 动态模糊 技术,拍摄时的控制一定的曝光时间(如每帧的一半),使得每一帧保留一定的物体轨迹(如同拍摄星轨图,长时间曝光,看到的是运行的轨迹,而不是一个点),帧是模糊的,但使得播放时看起来更连贯。 想看动态模糊效果的话,可以在播放视频时暂停,暂停时就显示视频的其中一帧。可以看到,当画面是在快速动的时候,暂停时看到的画面都不会很清晰,总有模糊感,如果画面变动的非常快,就会看到模糊得厉害,还带有残影,如果是画面运动幅度较小的,残影就不是很明显。视频帧的模糊,反而有利于运动画面的感觉连贯性。
而在游戏中,每一帧都是高清帧,正是由于高清帧,使得辨别度太高,所以低帧率时人眼区分度更高,卡顿感更明显。通过对画面进行动态模糊处理,在低帧率时能得到更好的显示效果。 游戏一般出现的基准帧率为:30, 60, 120, 240 FPS
屏幕刷新频率 是指显示器每秒能显示的帧数,单位是赫兹 (HZ)。每隔一定时间,显示器从显存中读取数据,并将画面显示出来。一般的笔记本多为液晶显示器,液晶显示器的刷新率为 60HZ 左右。 可以通过在桌面空白处鼠标右键单击,在右键菜单中选择 “显示设置”,点击 “高级显示设置” 就能看到显示器信息。 常见显示器刷新频率也有 144HZ的,这样能看到更加流畅的效果。还有高达240HZ 的显示器。 游戏帧率也要配合显示器刷新频率,即使游戏帧率为 240FPS,显示器刷新频率为 60HZ,那么显示出来最多也只能是 60FPS。同样,显示器刷新频率为 144HZ,游戏帧率为 60HZ,那么显示效果也只能是 60FPS 。 当帧率和刷新频率不一致时,有可能会产生 画面撕裂 现象,可以通过 **垂直同步(V-Sync)**技术 、G-Sync 和 Free-Syhc 技术降低影响。
EGE中,初始化完图形环境后,就可以使用绘图函数进行绘画。默认是自动刷渲染,即时间到达或绘制次数累积足够都可能会触发刷新,显示到屏幕上。当绘制比较复杂时,处于绘制过程中的刷新会造成画面闪烁,所以一般是设置成 手动渲染 模式。 手动渲染模式一般在 initgraph() 中第三个参数设置一次即可,不要在程序中多次设置,这样反而会引起闪烁,也没必要。 绘制过程中,由于都是写到同一块缓存区上,如果能够将上一帧的绘制内容完全覆盖,那就无需清屏,否则需要使用清屏函数,将上一帧的绘制内容清除,避免留下痕迹。清屏分为 部分清屏 和 全部清屏,根据需要进行选取,如果在一小块区域内能很容易地将痕迹清除,那就使用部分清屏,在对应区域绘制一个背景色填充矩形将其覆盖即可。 EGE 绘图函数分为普通函数和带抗锯齿的高级函数。可以在效率和美观两者之间进行取舍。如果不是非常复杂耗时,绘制圆,非45度斜线等用抗锯齿绘图函数比较好。
EGE 中 帧率控制 使用 delay_fps(),延时使用 delay_ms() 。控制帧率可以使画面保持一定的流畅度,延时又可以减少CPU性能的消耗。 由于以上显示器刷新频率和画面流畅度等原因,控制在和显示刷新频率一致的60FPS 比较好。
实际运行时,如果绘制每一帧耗费的时间都比较长,就达不到帧率要求。可以通过调用 getfps() 获取帧率。
float getfps();返回的是帧率 (FPS)。 调用如下,然后可以通过 xyprintf() 等显示出来。
float fps = getfps(); xyprintf(0, 0, "FPS:%.3f", fps);游戏和图形界面的本质是绘图,动态的画面更需要不断地进行绘图。然而绘图不是随意的,需要遵循一定的规则来呈现。帧循环在程序中是负责渲染画面的部分。 在游戏中,游戏帧循环也叫游戏主循环,会不断地重复一下动作:
处理用户输入(鼠标,键盘等),定时事件更新数据绘图 需要在循环中不断地获取用户输入,否则用户就无法控制游戏运行,并且处理用户实时性要好,否则就会有明显的延迟卡顿。因为需要不断地进行绘图,所以最好设置 手动渲染模式,提升绘制效率并减少闪烁。 EGE中帧循环结构大致如下:
for ( ; is_run(); delay_fps(60)) { //处理用户输入、定时等时间 handleEvent(); //更新数据 updateData(); //绘图 cleardevice(); draw(); }关于上图中 cleardevice() 的位置 ,cleardevice() 是用来清屏的,目的是将之前画面清除,以免影响本次的绘制内容。清屏操作是非常快的,不用担心影响帧率,该考虑是清屏后的重绘问题,可对复杂的不变的画面部分缓存到图像上,绘制帧时直接绘制到窗口上即可。
下面绘制一个旋转太极图,圆形这种,一般开启抗锯齿,否则绘制出来的效果极差。(下图中录制生成gif导出只用黑白两色,所以边沿有锯齿)
太极图由黑白双鱼组成,如下图,先画两个半圆,再分别画鱼头部分即可,既然是动画,那肯定得变化,这里变化的就是太极图的角度,通过不断改变旋转的角度,然后清屏绘制,就会看到不断旋转的动图。
#include <graphics.h> #include <cmath> //以(cx, cy)为中心的圆环,外圆半径为outerRad, 颜色为outerColor,内圆半径为innerRad, 颜色为innerColor void annulus(float cx, float cy, float outerRad, color_t outerColor, float innerRad, color_t innerColor); //以(cx, cy)为中心绘制半径为radius的角度为angle的太极图 void taiJi(float cx, float cy, float radius, float angle); int main() { initgraph(640, 640, INIT_RENDERMANUAL); setbkcolor(WHITE); //开启抗锯齿,使圆更平滑 ege_enable_aa(true); float angle = 0; //帧循环 for (; is_run(); delay_fps(60)) { //清屏 cleardevice(); //绘制太极图 taiJi(320, 320, 200, angle); //角度变化 angle += PI / 8; } getch(); closegraph(); return 0; } //圆环 void annulus(float cx, float cy, float outerRad, color_t outerColor, float innerRad, color_t innerColor) { setfillcolor(outerColor); ege_fillellipse(cx - outerRad, cy - outerRad, 2 * outerRad, 2 * outerRad); setfillcolor(innerColor); ege_fillellipse(cx - innerRad, cy - innerRad, 2 * innerRad, 2 * innerRad); } //太极 void taiJi(float cx, float cy, float radius, float angle) { float left = cx - radius, top = cy - radius; float width = 2 * radius, height = 2 * radius; color_t colWhite = EGEACOLOR(0xFF, WHITE), colBlack = EGEACOLOR(0xFF, BLACK); //白半圆 setfillcolor(colWhite); ege_fillpie(left, top, width, height, angle + 90, 180); //黑半圆 setfillcolor(colBlack); ege_fillpie(left, top, width, height, angle - 90, 180); //鱼眼中心偏移位置 float radian = (angle + 90) * PI / 180; float dx = radius / 2 * cos(radian), dy = radius / 2 * sin(radian); //黑鱼头部 annulus(cx + dx, cy + dy, radius / 2, colBlack, radius / 6, colWhite); //白鱼头部 annulus(cx - dx, cy - dy, radius / 2, colWhite, radius / 6, colBlack); //太极黑边框 setlinewidth(2); setcolor(colBlack); ege_ellipse(left, top, 2 * radius, 2 * radius); }上面有棵大白菜,用(二十四)章中的 Gif 类对其进行加载,然后不断地绘制其每一帧,就能形成动图,许多小游戏中的人物行走动作,也是如此。 由于 Gif 类内置时间计算,会根据 play() 调用时间和当前时间计算出应该绘制哪一帧(Gif动图每一帧是有延时时间),所以不需要手动控制绘制哪一帧,但是如果想要自己控制,也是可以的,调用 drawFrame( i) 即可绘制第 i 帧,并且超出帧数后会自动循环。 如果是帧序列图像,可以读取到 PIMAGE 数组,然后自行根据时间或者帧来绘制对应的图像帧。 下面是Gif动图自行绘制帧的一个示例,需要使用上面的西蓝花动图,可以到上面将图片另存为,然后在程序中将图片名改一下(相对路径和绝对路径,文件名,这个懂的吧?),需要注意的是,字符串前面需要加个 L,宽字符。
#include <graphics.h> #include "Gif.h" int main() { initgraph(800, 600, INIT_RENDERMANUAL); setbkcolor(WHITE); Gif gif(L"上面西蓝花的那个动图.gif"); gif.play(); for (; is_run(); delay_fps(60)) { //清屏 cleardevice(); gif.draw(); } closegraph(); return 0; }下面则是手动控制绘制 Gif 的每一帧帧
#include <graphics.h> #include "Gif.h" int main() { initgraph(800, 600, INIT_RENDERMANUAL); setbkcolor(WHITE); Gif gif(L"小白菜.gif"); //获取第一帧延时,作为帧延时 int delayTime = gif.getDelayTime(0); if (delayTime == 0) delayTime = 20; int frame = 0; for (; is_run(); delay_fps(60)) { //清屏 cleardevice(); //计算当前帧 int curFrame = int(frame++ * (1000.0 / 60) / delayTime); gif.drawFrame(curFrame); } closegraph(); return 0; }使用抗锯齿绘制的一个小球,在窗口内不断碰撞 反弹,颜色和透明度随时间随变化,有淡入淡出效果。
#include <graphics.h> int main(void) { initgraph(640, 480, INIT_RENDERMANUAL); setbkcolor(EGERGB(0xFF, 0xFF, 0xFF)); setcolor(BLACK); setbkmode(TRANSPARENT); setfont(16, 0, "黑体"); //开启抗锯齿 ege_enable_aa(true); const int speed = 2; //速度常量 int r = 80; //半径 int cx = r, cy = r; //圆心 int dx = speed, dy = speed; //x, y方向上分速度 int alpha = 0, da = 1; //alpha为当前alpha值,da为alpha变化增量 int colH = 0; //HSV颜色中的H值(色调) for (; is_run(); delay_fps(60)) { //位置更新 cx += dx; cy += dy; //碰撞检测 if (cx - r <= 0) dx = speed; //碰左 if (cy - r <= 0) dy = speed; //碰上 if (cx + r >= getwidth() - 1) dx = -speed; //碰右 if (cy + r >= getheight() - 1) dy = -speed; //碰下 // 改变alpha值,参数范围为 0 ~ 0xFF(255) alpha += da; if (alpha <= 0) da = 1; if (alpha >= 0xFF) da = -1; if (++colH > 360) colH = 0; color_t color = HSVtoRGB(colH, 1, 1); cleardevice(); setfillcolor(EGEACOLOR(alpha, color)); //设置颜色 ege_fillellipse(cx - r, cy - r, 2 * r, 2 * r); //绘制小球 xyprintf(0, 0, "FPS:%.3f", getfps()); //显示帧率 } closegraph(); return 0; }在碰撞小球的基础上,将小球封装成结构体,记录小球的数据信息,然后就可以同时显示多个小球。每个小球有初始化,更新数据,绘制动作。 很多人初学对多线程有误解,认为多个东西运动就得用多线程。每一帧里对所有小球的位置重新计算一遍即可,完成这个计算并不需要多线程,相反,多线程反而使得图形绘制的顺序混乱。如果不是对于线程操作很熟,不要乱用多线程。
不要想着多个小球就得用多线程,这和多线程没啥关系,只要在每一帧里对多个小球更新绘制即可。
#include <graphics.h> #include <stdlib.h> #include <time.h> const int speed = 2; //速度常量 const int SCREEN_WIDTH = 640; const int SCREEN_HEIGHT = 480; typedef struct Circle { int r; //半径 int cx, cy; //圆心 int dx, dy; //x, y方向上分速度 int alpha, da; //alpha为当前alpha值,da为alpha变化增量 int colH; //HSV颜色中的H值(色调) }Circle; void initCircle(Circle* circle) { //大小为[40, 60) circle->r = 40 + rand() % 20; //位置限制在窗口内 circle->cx = circle->r + rand() % (getwidth() - 2 * circle->r); circle->cy = circle->r + rand() % (getheight() - 2 * circle->r); //速度为[-1, 1] circle->dx = rand() % 3 - 1, circle->dy = rand() % 3 - 1; //随机颜色和透明度 circle->alpha = rand() % 256; circle->da = rand() % 2 * 2 - 1; // 值为-1或1 circle->colH = rand() % 360; } void updateCircle(Circle* circle) { //位置更新 circle->cx += circle->dx; circle->cy += circle->dy; //碰撞检测 if (circle->cx - circle->r <= 0) circle->dx = speed; //碰左 if (circle->cy - circle->r <= 0) circle->dy = speed; //碰上 if (circle->cx + circle->r >= getwidth() - 1) circle->dx = -speed; //碰右 if (circle->cy + circle->r >= getheight() - 1) circle->dy = -speed; //碰下 // 改变alpha值,参数范围为 0 ~ 0xFF(255) circle->alpha += circle->da; if (circle->alpha <= 0) circle->da = 1; if (circle->alpha >= 0xFF) circle->da = -1; //更新色相 if (++circle->colH >= 360) circle->colH = 0; } void drawCircle(Circle* circle) { //计算颜色 color_t color = HSVtoRGB(circle->colH, 1, 1); //设置颜色并绘制 setfillcolor(EGEACOLOR(circle->alpha, color)); ege_fillellipse(circle->cx - circle->r, circle->cy - circle->r, 2 * circle->r, 2 * circle->r); //绘制小球 } int main(void) { initgraph(640, 480, INIT_RENDERMANUAL); //创建窗口,手动渲染 setbkcolor(BLACK); //背景色 setcolor(WHITE); //前景色(在这里就只和文字颜色有关) setbkmode(TRANSPARENT); //文字背景色透明 setfont(16, 0, "黑体"); //设置字体 srand(time(NULL)); //随机数种子 //开启抗锯齿 ege_enable_aa(true); //创建多个小球 const int numCircle = 20; Circle circle[20]; //初始化 for (int i = 0; i < numCircle; i++) initCircle(&circle[i]); for (; is_run(); delay_fps(60)) { //更新 for (int i = 0; i < numCircle; i++) { updateCircle(&circle[i]); } //清屏 cleardevice(); //绘制 for (int i = 0; i < numCircle; i++) { drawCircle(&circle[i]); } xyprintf(0, 0, "FPS:%.3f", getfps()); //显示帧率 } closegraph(); return 0; }