LOADING...

加载过慢请开启缓存(浏览器默认开启)

loading

游戏编程模式④(双缓冲模式,游戏循环)

2021/8/21

本文作为《Game Programming Pattern》(游戏编程原理)的摘要总结,记录为期一个月的阅读感想与心得体会。

撰写本文时,希望可以尽量遵循star法则,此后的文章同样以这四个要点作为大纲。

situation(情境)

task(任务)

action(行为)

result(结果)

本文代码尽量以unity c#进行演示(最近看的都是cpp代码,时常有被带偏的情况,本人实在太菜菜子了)


双缓冲模式

我们的游戏世界以时间为单位更新,而每个引擎都必须处理渲染的问题。

同一时间内只会渲染一块内容,如果在计算的同时就开始渲染,那么屏幕只会出现一部分的色彩,这样显示出来则会是割裂的。

所以我们准备了一个缓冲区,我们在屏幕看到的色彩值其实往往是GPU算出的上一帧的数据。

也就是说,为了维护画面的完整性,在渲染中我们准备了两个区域。

一个是后台缓冲区,专门用于写入数据(可能需要一些时间但没关系)

而另一个是当前缓冲区,它已经准备好展示数据了。

当写入完成时两个缓冲区进行交换。

将旧的后台缓冲区替换上去成为新的当前缓冲区,为我们所见;

而之前的当前缓冲区则替换下来成为如今的后台缓冲区,用于写入新的数据。


situation

我们需要维护一些被逐步改变着的状态量。

同个状态可能会在其被修改的同时被访问到。

我们希望避免访问状态的代码能看到具体的工作过程。

我们希望能够读取状态但不希望等到写入操作的完成。


task

在渲染中,我们需要解决随时间计算并更新带来的画面割裂问题。

在其他情况下,我们需要解决对状态同时进行访问和修改的冲突(渲染也在其中不是吗)


action

原文使用的是c++,交换只是一个指针重定向的过程。

若不能支持指针重定向,则需要考虑数据拷贝。

个人感觉这个东西很底层,在编写unity c#脚本时并不常用,所以这里分析一下原文的c++代码:

class Framebuffer
    {
    public:
        Framebuffer() {}
        ~Framebuffer() {}

        void clear()
        {
            for (int i = 0; i < kWidth*kHeight; ++i)
            {
                pixels_[i] = 0;
            }
        }

        void draw(int x, int y)
        {
            pixels_[y*kWidth + x] = 1;
        }

        const char* getPixels()
        {
            return pixels_;
        }

    private:
        static const int kWidth = 160;
        static const int kHeight = 120;
        char pixels_[kWidth * kHeight];
    };

    class Scene
    {
    public:
        void draw()
        {
            buffer_.clear();
            buffer_.draw(1, 1);
            buffer_.draw(1, 2);
            buffer_.draw(3, 2);
        }

        Framebuffer& getBuffer()
        {
            return buffer_;
        }

    private:
        Framebuffer buffer_;
    };

以上是没有使用双缓冲模式的代码,不难看出,由于Scene将getBuffer()这一接口暴露了出来,因此显卡驱动可以在任何时候调用getBuffer(),就好比这样:

buffer_.draw(1, 1);
buffer_.draw(4, 1);
//显卡驱动调用
buffer_.draw(1, 3);
buffer_.draw(2, 4);
buffer_.draw(3, 4);
buffer_.draw(4, 3);

发生这种情况时,我们的画面可能尚未渲染完成,割裂就因此产生了

我们采取双缓冲模式。

class Scene
{
public:
  Scene()
  : current_(&buffers_[0]),
    next_(&buffers_[1])
  {}
 
  void draw()
  {
    next_->clear();
 
    next_->draw(1, 1);
    // ...
    next_->draw(4, 3);
 
    swap();
  }
 
  Framebuffer& getBuffer() { return *current_; }
 
private:
  void swap()
  {
    // Just switch the pointers.
    Framebuffer* temp = current_;
    current_ = next_;
    next_ = temp;
  }
 
  Framebuffer  buffers_[2];
  Framebuffer* current_;
  Framebuffer* next_;
};

现在scene有两个缓冲区,被置于数组 buffers_中。我们通过current和next两个指针来引用指向数组。

我们进行绘图时,往next这个后台缓冲区进行写入,当完成时就交换两个缓冲区。而无论何时我们尝试获取像素信息时,我们都从完整的一个当前缓冲区current中获取,这样就解决了因同时进行访问和修改的冲突造成的画面割裂问题。


result

我们需要考虑到缓冲区的粒度。

假如缓冲区是单个整体(庞大的代码块),那么全局就只有一对缓冲区,也就是我们示例中的情况。我们只需要进行两次指针分配就行。

若许多对象都持有一块数据,我们需要遍历对象集合,通知每个对象进行交换,这样可能会花去一定时间。


游戏循环

实现用户输入和处理器速度在游戏进行时间上的解耦。

游戏循环将在游戏过程中持续运转,每循环一次,就非阻塞的处理用户的输入,更新游戏状态,并渲染游戏到屏幕上。


situation

anytime

只要是游戏开发必将使用到游戏循环。


task

随着pc和各类游戏机的性能日益提高,cpu的运转速度变得飞快,开发环境对比以前性能低下的红白机小霸王已经有了很大的改变,因此我们不得不考虑性能这一因素。

同时我们也必须考虑让游戏在不同性能的机器上能尽量有相同的表现。大部分情况下我们并不了解自己游戏运行的硬件平台拥有怎样的性能,因此我们如果想让游戏适配多种机型,那就得让游戏在一个与性能无关的速度常量下运行。


action

让我们看看最简单的游戏循环:

while(true)
{
processInput();
update();
render();
}

这个简单循环的主要问题是它忽略了时间,游戏会尽情的飞奔。

在性能低下的机器上游戏会运行的很慢很卡,在运行飞快的机器上则会跑的飞快,可能需要玩家有超人的判断力。

若我们希望游戏以恒定60帧运行,则大概有16毫秒处理每一帧。我们所需要做的则是在16毫秒内处理这一帧,然后等待下一帧进行。我们称之为恒定时间步长。

while(true)
{
    double start=getCurrentTime();
    processInput();
    update();
    render();
    
    sleep(start+MS_PER_FRAME-getCurrentTime());
}

我们可以自由调整MS_PER_FRAME。

若我们性能足以在16毫秒内完成这一帧,就可以确保游戏不会运行的太快。但如果游戏运行过慢时,这将起到副作用。

这个模式的特点是:

它非常容易编写。

防止整个游戏因为跳帧而引起画面的撕裂。

性能较低的硬件会显得更慢;在性能低下的机器可能出现一步十卡、一卡十步的跳帧现象,这样的问题会毁掉你的游戏。

而性能高的硬件则浪费了硬件资源。牛逼的机器运行这个游戏可以轻松的跑到300帧,但你却限制它最高只能以60帧更新,拥有牛逼硬件的玩家无法尽情发挥其硬件效果产生极大的挫败感。但在移动平台上这可能是一件好事,它能避免游戏持续高速运行导致的快速耗电。

我们来看看下一种游戏循环。我们尝试用这一帧减去上一帧的时间来作为更新的步长,这样我们可以让游戏尽可能的飞奔,并且让依据FPS来决定游戏速度。这样游戏会越来越接近实际时间。我们称为变值/浮动时间步长:

double LastTime=getCurrentTime();
while(true)
{
    double current=getCurrentTime();
    double elapsed=current-LastTime
    processInput();
    update(elapsed);
    render();
    current=LastTime;
}

我们达成了:

随着每一帧所需的时间步长增加,子弹在每一帧越飞越远。而无论他在慢的机器(4大步)还是在快的机器上(10小步),都是在相同的时间中移动了同样的距离。

游戏在不同的硬件上都能以相同的速率运行。

且高端硬件玩家会有更流畅的游戏体验。

但设计模式一直都是一个相对的东西,这种循环同样不是十全十美的:

这使得游戏变得不再具有确定性(一样的输入会获得完全一致的输出)

ps:计算机天生具有确定性,但当将现实世界的变量(诸如时间,网络,线程计时器)掺杂进来时,就会变得不确定。

游戏大多使用浮点数进行运算,每两次浮点数相加就会产生一定的误差,而在更新频率越快的机器上这个误差将越大。而且不管运行它的硬件怎样,都可能出现严重的问题。

我们来看看改进之后的方案。我们采取恒定的游戏步长和不固定的渲染步长:

double previous = getCurrentTime();
double lag=0.0;
while(true)
{
    double current=getCurrentTime();
    double elapsed=current-previous
    previous=current;
    lag+=elapsed;
    processInput();
    
    while(lag>=MS_PER_FRAME)
    {
        update();
        lag-=MS_PER_FRAME;
    }
    render();
}

如此,游戏会以稳定的速度更新,渲染速度也尽可能的快。

而且以固定步长更新将使物理系统和AI都更加稳定。

同时我们将渲染从更新循环中分离出来,节省了CPU周期。

如果渲染速度超过了更新速度的话,有一些帧的画面将会是完全相同的。

也就是说渲染帧率最高等于游戏更新帧率。

这看起来不错,但仍然有改进的空间:

我们的更新是十分紧凑且固定的,而渲染的频率低于更新且不稳定,这导致我们可能并不在游戏更新的时候渲染,这样动作可能会显得并不那么流畅。

我们引入:

render(lag/MS_PER_FRAME)

诸如这样的插值运算,以lag(渲染时两帧的时间间隔)和帧率来预言计算物体应该出现的位置。由于游戏速度是恒定的,计算错误的情况停留在画面上的时间极短,难以发现,并无大碍。

在性能低的机器上,游戏循环会出现掉帧,但是游戏速度不受到影响。渲染速度会被降低。

在性能高的机器上,游戏循环不会出现问题,屏幕更新却可以非常快。

这种使游戏状态的更新独立于FPS的解决方案似乎是最好的游戏主循环实现。

唯一的缺点可能是它有点复杂了,并不容易编写,有许多细节需要实现(需要协调在高端机器上能跑的足够平滑,低端机器上不会让游戏跑的太慢)。


result

游戏主循环对游戏的影响远远超乎你的想象。

有一个方案是要坚决避免的,那就是可变帧率来决定游戏速度的方案(让游戏随着性能尽可能跑得快)。

恒定的帧率的方案对移动设备而言可能是一个很好的实现。

如果想展示你的硬件全部的实力,那么最好使用FPS独立于游戏速度的实现方案。

如果不想麻烦的实现一个预言函数则需要找到一个帧率大小的平衡点。