Подтвердить что ты не робот

Android game loop vs обновление в потоке рендеринга

Я делаю андроид-игру, и в настоящее время я не получаю производительность, которую я бы хотел. У меня есть игровой цикл в своем потоке, который обновляет позицию объекта. Поток рендеринга будет перемещать эти объекты и рисовать их. Текущее поведение - это то, что кажется прерывистым/неравномерным движением. Я не могу объяснить, что до того, как я поместил логику обновления в свой собственный поток, я использовал его в методе 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, но его игра выглядит очень гладко. Кто-нибудь есть какие-то идеи или кто-нибудь испытал то, что я описываю?

--- EDIT: 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. Это привело к тому, что один кадр отобразил результат двух (или более) обновлений, более крупный скачок, чем обычно.

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

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

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

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

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

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

Спасибо

4b9b3361

Ответ 1

(Ответ от 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() не дает никаких конкретных гарантий относительно точности или минимальной продолжительности сна.

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

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

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

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

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

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

Обновление: Я написал еще более длинный ответ по аналогичному вопросу.

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

Ответ 2

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

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

EDIT: В качестве примера, как это делает 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;
    }
  }
}