Android-игра цикл против обновления в потоке рендеринга

Я делаю игру для Android и в настоящее время не получаю желаемую производительность. У меня есть игровой цикл в своем собственном потоке, который обновляет позицию объекта. Поток рендеринга будет проходить через эти объекты и рисовать их. Текущее поведение - это то, что кажется прерывистым / неравномерным движением. Что я не могу объяснить, так это то, что до того, как я поместил логику обновления в ее собственный поток, я поместил ее в метод onDrawFrame, прямо перед вызовом gl. В этом случае анимация была совершенно плавной, она становится прерывистой / неровной, особенно когда я пытаюсь регулировать цикл обновления через Thread.sleep. Даже когда я позволяю потоку обновления приходить в бешенство (без сна), анимация плавная, только когда задействован Thread.sleep, это влияет на качество анимации.

Я создал скелетный проект, чтобы увидеть, смогу ли я воссоздать проблему, ниже приведены цикл обновления и метод onDrawFrame в рендере: Update Loop

    @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 мс, и сохранил дополнительное время в остатке. Этот остаток будет распределяться в последующие временные дельты в течение следующих нескольких обновлений, если они смогут справиться с этим. Эта идея предотвращает все внезапные длинные прыжки, по существу сглаживая скачок времени в нескольких кадрах. Это дало мне отличные результаты.

Недостатком этого подхода является то, что в течение небольшого времени положение объектов не будет точным со временем и будет фактически ускоряться, чтобы компенсировать эту разницу. Но это плавнее и изменение скорости не очень заметно.

Наконец, я решил переписать свой двигатель с учетом двух вышеупомянутых идей, а не исправлять двигатель, который я изначально сделал. Я сделал некоторые оптимизации для синхронизации потоков, которые, возможно, кто-то может прокомментировать.

Мои текущие темы взаимодействуют так:
- Обновление потока обновляет текущий буфер (система с двойным буфером, чтобы обновлять и рисовать одновременно), а затем передает этот буфер средству визуализации, если предыдущий кадр был нарисован.
-Если предыдущий кадр еще не прорисован или уже рисует, поток обновления будет ждать, пока поток визуализации не уведомит его о том, что он нарисовал.
- Поток рендеринга ожидает, пока поток уведомлений не уведомит о том, что произошло обновление.
Когда поток рендеринга рисует, он устанавливает "последнюю переменную отрисовки ", указывающую, какой из двух буферов он нарисовал последним, а также уведомляет поток обновления, если он ожидал отрисовки предыдущего буфера.

Это может быть немного запутанным, но то, что он делает, учитывает преимущества многопоточности в том, что он может выполнять обновление для кадра n, в то время как кадр n-1 рисует, а также предотвращает множественные итерации обновления для кадра, если средство визуализации принимает много времени. Для дальнейшего объяснения этот сценарий множественного обновления обрабатывается блокировкой потока обновления, если он обнаруживает, что буфер lastDrawn равен только что обновленному. Если они равны, это указывает потоку обновления, что предыдущий кадр еще не нарисован.

Пока что я получаю хорошие результаты. Дайте мне знать, если у кого-нибудь есть комментарии, буду рад услышать ваши мысли о том, что я делаю, правильно или неправильно.

Спасибо

2 ответа

Решение

(Ответ от Blackhex поднял некоторые интересные моменты, но я не могу втиснуть все это в комментарий.)

Наличие двух потоков, работающих асинхронно, неизбежно приведет к таким проблемам. Посмотрите на это так: событие, которое управляет анимацией, является аппаратным сигналом "vsync", то есть точкой, в которой поверхностный композитор Android предоставляет новый экран, полный данных для оборудования дисплея. Вы хотите иметь новый фрейм данных всякий раз, когда приходит vsync. Если у вас нет новых данных, игра выглядит нестабильной. Если вы сгенерировали 3 кадра данных за этот период, два будут проигнорированы, и вы просто тратите время автономной работы.

(Заполнение процессора может также привести к нагреву устройства, что может привести к тепловому дросселированию, что замедляет работу всей системы... и может привести к прерыванию анимации.)

Самый простой способ синхронизироваться с дисплеем - выполнить все обновления состояния в onDrawFrame(), Если иногда для обновления вашего состояния и его рендеринга требуется больше одного кадра, то вы будете выглядеть плохо, и вам нужно изменить свой подход. Простое перемещение всех обновлений состояния игры на второе ядро ​​не поможет так сильно, как хотелось бы - если ядро ​​№ 1 - это поток рендеринга, а ядро ​​№ 2 - поток обновления состояния игры, то ядро ​​№ 1 собирается бездействовать, пока ядро ​​№ 2 обновляет состояние, после чего ядро ​​№ 1 возобновит выполнение фактического рендеринга, а ядро ​​№ 2 будет бездействовать, и это займет столько же времени. Чтобы реально увеличить объем вычислений, которые вы можете выполнять для каждого кадра, вам нужно иметь два (или более) ядра, работающих одновременно, что поднимает некоторые интересные проблемы синхронизации в зависимости от того, как вы определяете свое разделение труда (см. http://developer.android.com/training/articles/smp.html, если вы хотите пойти по этому пути).

Попытка использовать Thread.sleep() управлять частотой кадров вообще плохо кончается. Вы не можете знать, сколько длится период между vsync или как долго наступает следующий. Это отличается для каждого устройства, и на некоторых устройствах оно может быть переменным. По сути, вы получаете два такта - vsync и sleep - которые бьют друг друга, и в результате получается прерывистая анимация. Более того, Thread.sleep() не дает никаких особых гарантий относительно точности или минимальной продолжительности сна.

Я на самом деле не прошел через источники острова Реплики, но в GameRenderer.onDrawFrame() вы можете увидеть взаимодействие между их потоком состояния игры (который создает список объектов для рисования) и потоком рендеринга GL (который просто рисует список). В их модели состояние игры обновляется только по мере необходимости, и если ничего не изменилось, оно просто перерисовывает предыдущий список розыгрыша. Эта модель хорошо работает для событийно-ориентированной игры, т.е. когда содержимое на экране обновляется, когда что-то происходит (вы нажимаете клавишу, срабатывает таймер и т. Д.). Когда происходит событие, они могут выполнить минимальное обновление состояния и соответствующим образом настроить список отрисовки.

С другой стороны, поток рендеринга и состояние игры работают параллельно, потому что они не связаны жестко. Состояние игры просто обновляется по мере необходимости, и поток рендеринга блокирует его при каждой vsync и отрисовывает все, что находит. Пока ни одна из сторон не держит что-либо взаперти слишком долго, они явно не мешают. Единственное интересное общее состояние - это список отрисовки, защищенный мьютексом, поэтому их многоядерные проблемы сведены к минимуму.

Для Android Breakout ( http://code.google.com/p/android-breakout/) игра непрерывно движется по мячу. Там мы хотим обновлять наше состояние так часто, как нам позволяет отображение, поэтому мы отключаем изменение состояния с помощью vsync, используя временную дельту из предыдущего кадра, чтобы определить, насколько далеко продвинулись события. Покадровое вычисление невелико, а рендеринг довольно прост для современного устройства GL, поэтому он легко умещается за 1/60 секунды. Если бы дисплей обновлялся намного быстрее (240 Гц), мы могли бы время от времени пропускать кадры (опять-таки, вряд ли это будет замечено), и мы будем сжигать в 4 раза больше ЦП при обновлении кадров (что не очень хорошо).

Если по какой-либо причине в одной из этих игр пропущена синхронизация, игрок может заметить или не заметить. Состояние улучшается по истекшему времени, а не по заранее заданному представлению о "кадре" фиксированной длительности, поэтому, например, шар будет либо перемещаться на 1 единицу в каждом из двух последовательных кадров, либо в 2 единицы в одном кадре. В зависимости от частоты кадров и чувствительности дисплея это может быть невидимым. (Это ключевой вопрос дизайна, который может запутаться в вашей голове, если вы представляете свое игровое состояние в терминах "галочек".)

Оба из них являются действительными подходами. Ключ должен нарисовать текущее состояние всякий раз, когда onDrawFrame и обновлять состояние как можно реже.

Примечание для тех, кто читает это: не используйте System.currentTimeMillis(), Пример в вопросе используется SystemClock.uptimeMillis(), который основан на монотонных часах, а не на настенных часах. Что, или System.nanoTime()Есть лучший выбор. (Я нахожусь в небольшом крестовом походе против currentTimeMillis, который на мобильном устройстве может внезапно прыгнуть вперед или назад.)

Обновление: я написал еще более длинный ответ на подобный вопрос.

Обновление 2: я написал еще более длинный ответ об общей проблеме (см. Приложение A).

Одна часть проблемы может быть вызвана тем, что Thread.sleep() не является точным. Попробуйте выяснить, каково реальное время сна.

Самая важная вещь, которая должна сделать ваши анимации гладкими, это то, что вы должны вычислить некоторый коэффициент интерполяции, назовите его альфа, который линейно интерполирует ваши анимации в последовательных вызовах потока рендеринга между двумя последовательными вызовами потока обновления анимации. Другими словами, если ваш интервал обновления высок по сравнению с частотой кадров, то не интерполировать шаги обновления анимации - это то же самое, что и при рендеринге с интервалом обновления кадров.

РЕДАКТИРОВАТЬ: В качестве примера, вот как 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;
    }
  }
}
Другие вопросы по тегам