Android游戏循环与在渲染线程中更新的区别

19

我正在制作一款安卓游戏,目前性能还不如我所期望的。我有一个独立线程中的游戏循环更新物体的位置。渲染线程会遍历这些对象并将它们绘制出来。当前的行为看起来是不流畅和不均匀的移动。我无法解释的是,在将更新逻辑放入独立线程之前,我将它放在onDrawFrame方法中,在GL调用之前。在那种情况下,动画是完美流畅的,只有在我尝试通过Thread.sleep来限制我的更新循环时,它才会变得不流畅/不均匀。即使我允许更新线程失控(没有睡眠),动画也是流畅的,只有当涉及到Thread.sleep时才会影响动画的质量。

我创建了一个骨架项目来看看是否可以重新创建问题,以下是渲染器中的更新循环和onDrawFrame方法:

更新循环
    @Override
public void run() 
{
    while(gameOn) 
    {
        long currentRun = SystemClock.uptimeMillis();
        if(lastRun == 0)
        {
            lastRun = currentRun - 16;
        }
        long delta = currentRun - lastRun;
        lastRun = currentRun;

        posY += moveY*delta/20.0;

        GlobalObjects.ypos = posY;

        long rightNow = SystemClock.uptimeMillis();
        if(rightNow - currentRun < 16)
        {
            try {
                Thread.sleep(16 - (rightNow - currentRun));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

这是我的onDrawFrame方法:

        @Override
public void onDrawFrame(GL10 gl) {
    gl.glClearColor(1f, 1f, 0, 0);
    gl.glClear(GL10.GL_COLOR_BUFFER_BIT |
            GL10.GL_DEPTH_BUFFER_BIT);

    gl.glLoadIdentity();

    gl.glBindTexture(GL10.GL_TEXTURE_2D, textures[0]);
    gl.glTranslatef(transX, GlobalObjects.ypos, transZ);
    //gl.glRotatef(45, 0, 0, 1);
    //gl.glColor4f(0, 1, 0, 0);

    gl.glEnableClientState(GL10.GL_VERTEX_ARRAY);
    gl.glEnableClientState(GL10.GL_TEXTURE_COORD_ARRAY);

    gl.glVertexPointer(3,  GL10.GL_FLOAT, 0, vertexBuffer);
    gl.glTexCoordPointer(2, GL10.GL_FLOAT, 0, uvBuffer);

    gl.glDrawElements(GL10.GL_TRIANGLES, drawOrder.length,
              GL10.GL_UNSIGNED_SHORT, indiceBuffer);

    gl.glDisableClientState(GL10.GL_VERTEX_ARRAY);
    gl.glDisableClientState(GL10.GL_TEXTURE_COORD_ARRAY);
}
我研究了复制岛的源代码,发现他在一个单独的线程中执行更新逻辑,并使用Thread.sleep进行限流,但他的游戏看起来非常流畅。有人有什么想法或者经历过我所描述的情况吗? ---编辑:1/25/13---

我花了一些时间思考并大大改善了这个游戏引擎。我是如何做到的可能会让真正的游戏程序员感到不满,所以请随时纠正我的任何想法。

基本思路是保持一个更新、绘制、更新、绘制的模式,同时保持时间差相对稳定(通常不受你控制)。我的第一步是同步我的渲染器,使它只有在被允许后才进行绘制。实现方法类似于:

public void onDrawFrame(GL10 gl10) {
        synchronized(drawLock)
    {
        while(!GlobalGameObjects.getInstance().isUpdateHappened())
        {
            try
            {
                Log.d("test1", "draw locking");
                drawLock.wait();
            } 
            catch (InterruptedException e) 
            {
                e.printStackTrace();
            }
        }
    }
当我的更新逻辑完成后,我调用了drawLock.notify(),释放渲染线程以绘制我刚刚更新的内容。这样做的目的是帮助确立更新、绘制…更新、绘制…等模式。
实现这个后,运行相对流畅得多,尽管我仍然偶尔会遇到移动跳跃的情况。在一些测试之后,我发现在ondrawFrame调用之间,我有多个更新正在进行。这会导致一个帧显示两个(或更多)更新的结果,比正常情况下的跳跃幅度还要大。
为了解决这个问题,我限制了两个onDrawFrame调用之间的时间差为某个值,比如18毫秒,并将额外的时间存储在余数中。如果后续的更新可以处理它,余数将分配给接下来的几个时间差。这种想法可以防止所有突然的长跳,基本上平滑了多个帧的时间峰值。这样做给了我很好的结果。
这种方法的缺点是,在一小段时间内,物体的位置不会与时间准确匹配,实际上会加速以弥补差异。但是这样会更平滑,变化的速度并不会非常明显。
最后,我决定按照上述两个想法重新编写我的引擎,而不是修补我最初做的引擎。我对线程同步进行了一些优化,也许有人可以评论一下。
我的当前线程交互方式如下:
-更新线程更新当前缓冲区(双缓冲系统以便同时更新和绘制),然后将此缓冲区提供给渲染器,如果已经绘制了前一帧。 -如果前一帧尚未绘制或正在绘制,则更新线程将等待渲染线程通知它已经绘制。 -渲染线程等待更新线程通知已发生更新。 -当渲染线程进行绘制时,它设置一个“last drawn variable”,指示其最后绘制了哪个缓冲区,并在它正在等待先前的缓冲区被绘制时通知更新线程。
这可能有点复杂,但这样做既充分利用了多线程的优势,因为它可以在绘制n-1帧时执行n帧的更新,同时还可以防止渲染器花费很长时间时出现多次更新迭代的情况。更进一步地说,这种多次更新的情况由更新线程锁定处理,如果它检测到lastDrawn缓冲区等于刚刚更新的缓冲区,则锁定。如果它们相等,这表明更新线程前一帧还没有被绘制。
到目前为止,我得到了不错的结果。如果有人对我所做的任何事情有任何评论,我很乐意听取您的想法,无论是正确的还是错误的。
谢谢
2个回答

17
(Blackhex的回答提出了一些有趣的观点,但我无法在评论中概括所有内容。)同时运行两个线程必然会导致此类问题。从这个角度来看:驱动动画的事件是硬件“vsync”信号,即Android表面合成器向显示硬件提供新的屏幕数据的时间点。每当vsync到达时,您都希望有新的数据帧。如果没有新数据,游戏看起来就会很卡顿。如果在那段时间内生成了3个数据帧,则将忽略其中两个,您只是浪费电池寿命。(全速运行CPU也可能导致设备发热,这可能会导致热量限制,减缓系统中的所有操作...并使您的动画变得卡顿。)
最简单的保持与显示同步的方法是在onDrawFrame()中执行所有状态更新。如果有时需要超过一帧时间来执行状态更新和渲染帧,则会显得很糟糕,需要修改方法。简单地将所有游戏状态更新转移到第二个核心并不能像您想象的那样有所帮助-如果核心#1是渲染器线程,核心#2是游戏状态更新线程,则当核心#2更新状态后,核心#1将空闲,然后核心#1将恢复实际渲染,而核心#2将空闲,这需要同样长的时间。要实际增加每帧可以进行的计算量,您需要同时使用两个(或更多)核心,这会引起一些有趣的同步问题,具体取决于您如何定义分工(如果您想深入了解,请参见http://developer.android.com/training/articles/smp.html)。
尝试使用Thread.sleep()来管理帧速率通常会以失败告终。您无法知道垂直同步之间的时间段有多长,或者下一个时间段何时到达。对于每个设备都是不同的,而且在某些设备上可能是可变的。您最终会得到两个时钟 - 垂直同步和休眠 - 相互碰撞,结果是动画不流畅。除此之外,Thread.sleep()并不会提供任何关于准确性或最小睡眠持续时间的特定保证。
我还没有详细查看 Replica Island 的源代码,但是在GameRenderer.onDrawFrame()中,您可以看到他们的游戏状态线程(创建要绘制的对象列表)与 GL 渲染器线程(仅绘制列表)之间的交互。在他们的模型中,仅在需要更新游戏状态时才进行更新,如果没有任何更改,则只需重新绘制先前的绘制列表。这种模型非常适用于事件驱动的游戏,即当发生某些事情时,屏幕上的内容会更新(您按下键,计时器触发等)。当事件发生时,他们可以进行最小状态更新并根据需要调整绘图列表。
以另一种方式来看,渲染线程和游戏状态之间是并行的,因为它们没有严格地绑在一起。游戏状态只是在需要时更新各种事物,而渲染线程则每个垂直同步锁定它,并绘制它找到的任何内容。只要两边都不保持任何东西太久,它们就不会明显地干扰。唯一有趣的共享状态是绘制列表,由互斥锁保护,因此它们的多核问题被最小化。
对于Android Breakout(http://code.google.com/p/android-breakout/),游戏中有一个球在持续运动中弹跳。在那里,我们希望尽可能频繁地根据显示器更新我们的状态,因此我们利用vsync驱动状态变化,使用前一帧的时间差确定事物已经推进了多远。每帧计算量很小,对于现代GL设备来说,渲染也相当简单,因此所有内容都可以轻松地适应1/60秒。如果显示器更新得更快(240Hz),我们可能偶尔会掉帧(同样不太可能被注意到),并且我们将在帧更新上消耗4倍的CPU(这很不幸)。
如果其中某个游戏错过了垂直同步(vsync),玩家可能会或者可能不会察觉到。状态是通过经过的时间而不是预设的固定“帧”时长来推进的,因此例如球会在连续两个帧中每个移动1个单位,或在一个帧上移动2个单位。根据帧率和显示器的响应能力,这可能是看不见的。(这是一个关键设计问题,如果你以“ticks”方式构想了你的游戏状态,这将会混淆你的思路。)
这两种方法都是有效的。关键是在每次调用onDrawFrame时绘制当前状态,并且尽可能地少更新状态。
请注意,对于其他人阅读这篇文章的人:不要使用System.currentTimeMillis()。题目中的示例使用了基于单调时钟而不是墙时钟时间的SystemClock.uptimeMillis()。那个或者System.nanoTime(),是更好的选择。(我正在反对currentTimeMillis,因为它在移动设备上可能会突然向前或向后跳转。)

更新: 我撰写了一篇更长的答案,来回答类似的问题。

更新2: 我在附录A中撰写了一篇更长更详细的答案,讨论了这个普遍问题。


感谢您的周到回答。自从我写原帖以来,已经过了几个星期,我一直在玩耍和深入研究这个问题。我发现将渲染器与更新同步,只有在更新之后才绘制,它会等待(就像在复制品中发生的那样),直到更新线程通知它有所改进,这对问题产生了很大影响。 - user1578101
实施后,我仍然注意到我的运动中出现了跳跃。这里发生的情况是,在一组绘图之间有时会发生两次更新。这本质上将球移动了其他帧的两倍远。我采取了限制两个渲染之间的时间差并将额外时间放入余数的方法,然后将其分配给后续的更新迭代。这使事情变得更加平滑。这样做的缺点是球在技术上加速以弥补失去的时间。但几乎不可察觉,并且不会超过平稳性。这是一个典型的方法吗? - user1578101
关于上面的问题和更新。请记住,您无法提前知道屏幕重绘之间的延迟时间 - 在某些设备上,甚至可能在游戏中更改 - 因此,试图以固定长度的时间框架思考将导致问题(丢失帧或双步帧)。短期内帧率应该是相当恒定的,因此您可以基于onDrawFrame调用之间经过的时间来确定下一帧推进的量;随着时间的推移,它应该看起来很平滑。(这就是Breakout所做的。) - fadden
我认为是这样的,但从我看到的情况来看,仅基于时间差更新会产生不连贯的结果。将大的时间差限制在一定范围内,然后在接下来的几帧中加速对象以弥补失去的时间,效果会更加平滑。如果有一种更好的方法来调节onDrawFrame调用,仅基于时间差就足够了,但我还没有找到如何做到这一点。 - user1578101

1
问题的一部分可能是由于Thread.sleep()不准确引起的。尝试调查睡眠的实际时间。
使您的动画平滑的最重要的事情是计算一些插值因子,称为alpha,在两个连续的动画更新线程调用之间线性插值您的动画。换句话说,如果您的更新间隔与帧速率相比较高,则不插值您的动画更新步骤就像您以更新间隔帧速率进行渲染一样。
编辑:例如,这就是PlayN如何做到的:
@Override
public void run() {
  // The thread can be stopped between runs.
  if (!running.get())
    return;

  int now = time();
  float delta = now - lastTime;
  if (delta > MAX_DELTA)
    delta = MAX_DELTA;
  lastTime = now;

  if (updateRate == 0) {
    platform.update(delta);
    accum = 0;
  } else {
    accum += delta;
    while (accum >= updateRate) {
      platform.update(updateRate);
      accum -= updateRate;
    }
  }

  platform.graphics().paint(platform.game, (updateRate == 0) ? 0 : accum / updateRate);

  if (LOG_FPS) {
    totalTime += delta / 1000;
    framesPainted++;
    if (totalTime > 1) {
      log().info("FPS: " + framesPainted / totalTime);
      totalTime = framesPainted = 0;
    }
  }
}

这是其中一种选择,您可以期望更新帧准确,并计算 alpha 为 delta time / 所需帧时间,但当帧时间变化很大时,可能会产生一些影响。您还可以使用此 delta time 值来平衡 Thread.sleep() 的不准确性 - 例如,当实际睡眠时间为 55 毫秒而不是 50 毫秒时,您可以在下一次睡眠时睡眠 45 毫秒。另一个选择是使用某些浮动平均值来预测更新时间。 - Blackhex
你甚至可以保存前几个更新状态和它们的时间(至少3个),并将渲染循环延迟两帧以与更新循环进行比较,以获得完全流畅的动画,但这可能难以实现且消耗内存。 - Blackhex
什么是最常见的方法,不会导致任何明显的帧“跳跃”? - android developer
据我现在的理解,问题在于更新逻辑在不同的线程中,当渲染器被触发时,对象的状态不能保证是最新的。更新可能在绘制前1毫秒或15毫秒之前发生。下一帧,由于Thread.sleep的差异或任何一个线程中的峰值,更新和绘制之间的时间可能会有所不同。对此还有其他想法吗? - user1578101
当更新线程以固定间隔更新(例如使用物理引擎),并且渲染线程尽可能地快速渲染(没有休眠)时,I也具有进一步的重要性。 - Blackhex
显示剩余4条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接