游戏编程模式--双缓冲模式


图形渲染

  我们先看一个典型的例子,每个游戏引擎都要处理的问题——渲染。当引擎渲染出用户看到的世界时,在同一时间它只渲染一块:远处山峰、欺负的丘陵、树木,这些轮流渲染;假如用户也逐步的观察视窗的渲染过程,那么看到的将是破碎的世界。这是我们不能接受的,场景比如平滑快速的更新,每一帧必须被完整的显示。如何解决这个问题?这就要用到本节讲述的双缓冲模式。

  首先我们回顾一下计算机是如何渲染图形的:

  计算机显示图形的设备就是我们常见的显示器,显示器从最早的CRT发展到现在的液晶显示器,硬件技术不断的发展,但显示的原理一直没变。显示器的屏幕其实由很多的小单元组成,一个单元我们可以称之为一个像素,根据屏幕的尺寸和分辨率,可以简单的计算出每个像素的物理尺寸。假设我们现在要显示一幅图像,很简单的把图像覆盖的点显示成对应的颜色即可。但这个过程并不是一个瞬间完成的,而是一个逐行扫描的过程,即从左到右,从上到下一个像素一个像素的着色。当扫描到屏幕右下角的时候,将重新定位到左上角重复这个过程。这个过程非常的快,在现代的显示器上可以达到60甚至是120次每秒,我们的肉眼是分辨不出这个过程的,所以我们所见的就是一张一张的静态图片。

  但我们又是如何知道哪些像素需要绘制成什么颜色了?答案就是缓存区,也就是计算机中存储这一个数组(它是RAM中的一个块,其中两个字节表示一个像素颜色),这个数组与屏幕像素对应(当然缓冲区大小不一定与屏幕分辨率一致,如何映射有相应的策略和算法),所以通过遍历缓冲区给对应的像素着色即可。但这里就可能存在一个问题,就是计算和渲染是同时进行的,即我们一边在更新缓冲区,一边再读取缓冲区,如果读取和更新的速度不一致,那么不管更新比读取快还是慢,都会导致当前屏幕显示的内容来源于两帧图像,造成一种撕裂的效果。那么对这个问题的处理就很简单了,等读取完成之后再更新缓冲区就行了。但很明显,这种做法会极大的降低帧率,这就诞生了双缓冲的做法。

双缓冲

  双缓冲,其实就是两个缓冲区,一个用于读,一个用于写(也就是更新),当更新完成后,把两个缓冲区交换(即更新好的缓冲用于读取,之前用于读取的缓冲用于存储新的更新数据);这就避免的上述情况的出现。这里有个很有趣的问题,就是何时交换缓冲区了?很明显的一个做法就是扫描重新定位到左上角的过程中。这里我们不过多的讲解显示器的问题,回到双缓冲模式。

  从双缓冲模式的定义上,我们就可以推断出它的使用场景:

  1.   我们需要维护一些被逐步改变着的变量;
  2.   同个状态可能在其被修改的同时被访问到;
  3.   我们希望避免访问状态的代码能看到具体的工作过程;
  4.   我们希望避免能够读取状态但不希望等待写入操作完成;

注意事项

  需要注意的是:

  1.双缓冲模式需要在状态写入完成后进行一次交换操作,操作必须是原子性的,也就是说任何代码都无法在这个交换期间对缓冲区内的任何状态进行访问。通常这个交换和分配一个指针的速度差不多,但如果交换用去了比修改初始状态更长的时间,那这个模式就毫无益处了;

  2.这个模式使用了两个缓冲区,所以一个直接的后果就是增加了内存的占用,所以如果你的内存受限,你就只能想其它的办法了。

示例代码

  我们先看看没有使用双缓冲的代码

    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向外提供了获取buffer的接口,外部代码就很容易在scene绘制的同时修改buffer,这个时候buffer中的数据就不是我们预期的数据了。接下来我们使用双缓冲修正它:

    class Scene
    {
    public:
        void draw()
        {
            next_->clear();
            next_->draw(1, 1);
            next_->draw(1, 2);
            next_->draw(3, 2);
            swap();
        }

        Framebuffer& getBuffer()
        {
            return *current_;
        }

        void swap()
        {
            Framebuffer* tmp = current_;
            current_ = next_;
            next_ = tmp;
        }

    private:
        Framebuffer buffers_[2];
        Framebuffer *current_;
        Framebuffer *next_;
    };

设计决策

  缓冲区如何交换?

  1.交换缓冲区指针或引用

    这个是处理图形缓冲区的最通用的解决方案。优点很明显:速度快,交换一对指针,其速度和简单性基本上不会被超越了;但约束就是外部代码无法存储一个持久化的指针指向缓冲区,这对于那些显卡希望缓冲区在内存冲固定地址的系统来说尤其会造成麻烦。

  2.在两个缓冲区之间进行数据拷贝

    这个与交换指针或引用相比,速度就要慢很多。但优点就是后台缓冲区的数据与当前数据就差一帧的时间,如果我们要访问上一帧的数据,这将带来极大的方便。

  缓冲区粒度

    也就是缓冲区如何组织?通常你所缓存的内容将会告诉你答案,当然我们也可以调整它们,比如每一个对象都有一个状态,把这个状态存在对象中是一种做法,也可以把所有对象的状态一起存在一个数组中,对象中只存储索引即可。

相关