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

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

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

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

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

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

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

Спасибо


person user1578101    schedule 29.12.2012    source источник


Ответы (2)


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

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

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

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

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

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

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

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

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

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

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

Обновление: я написал ещё больше ответ на аналогичный вопрос.

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

person fadden    schedule 15.01.2013
comment
Спасибо за вдумчивый ответ. У меня было несколько недель с тех пор, как я написал исходный пост, чтобы поиграть и углубиться в проблему. Я обнаружил, что синхронизация рендерера для отрисовки только после обновления, поскольку он будет ждать (как это происходит в реплике), пока поток обновления не уведомит об этом, имеет огромное значение. - person user1578101; 26.01.2013
comment
После того, как я это реализовал, я все еще замечал скачки в своем движении. То, что здесь происходило, заключалось в том, что между сериями розыгрышей иногда происходило два обновления. По сути, это сместило мяч вдвое дальше, чем в других кадрах. Я использовал подход, чтобы ограничить дельту времени между двумя рендерингами и поместить дополнительное время в остаток, который затем будет распределяться на последующие итерации обновления. Это значительно сгладило ситуацию. Недостатком этого является то, что мяч технически ускоряется, чтобы наверстать упущенное время. Но это малозаметно и плавности не перевешивает. Типичный ли это подход? - person user1578101; 26.01.2013
comment
Re: выше + обновления к вопросу. Помните, что вы не можете заранее знать, как долго длится задержка между перерисовками экрана — на некоторых устройствах она может измениться даже в середине игры — поэтому попытка мыслить в терминах фиксированных временных рамок может привести к проблемам. (отсутствующие кадры или двойной шаг на кадрах). Тем не менее, частота кадров должна быть довольно постоянной в краткосрочной перспективе, поэтому вы можете основывать сумму, которую вы продвигаете для следующего кадра, на прошедшем времени между вызовами onDrawFrame; со временем он должен выглядеть гладким. (Это то, что делает Breakout.) - person fadden; 29.01.2013
comment
Я думаю, что да, но, судя по тому, что я вижу, обновление только на основе временной дельты дает изменчивые результаты. Ограничение большой временной дельты, а затем ускорение объекта в течение следующих нескольких кадров, чтобы компенсировать потерянное время, выглядит намного более плавным. Если бы был какой-то способ лучше регулировать вызовы onDrawFrame, я думаю, было бы достаточно просто основывать его на временной дельте, но я пока не понял, как это сделать. - person user1578101; 29.01.2013

Одна часть проблемы может быть вызвана тем, что 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;
    }
  }
}
person Blackhex    schedule 29.12.2012
comment
такой подход кажется логичным. как бы вы получили время между кадрами? используя System.currentTimeMillis() и сохраняя время последнего обновления для каждой из анимаций? - person android developer; 29.12.2012
comment
Это один из вариантов, вы можете ожидать, что кадр обновления будет точным, и вычислить альфу как дельта-время/желаемое время кадра, но когда изменение времени кадра будет высоким, это может привести к некоторым влияниям. Вы также можете использовать это значение дельта-времени для балансировки неточности Thread.sleep() — например, когда фактическое время сна будет 55 мс вместо 50 мс, вы можете спать 45 мс в следующий раз. Другим вариантом может быть прогнозирование времени обновления с использованием некоторого плавающего среднего значения. - person Blackhex; 29.12.2012
comment
Кроме того, вы даже можете сохранить все несколько предыдущих состояний обновления, и они определяют время (не менее 3) и задерживают цикл рендеринга по сравнению с циклом обновления на два кадра, чтобы получить полностью плавную анимацию, но это может быть сложно реализовать и потреблять много памяти. - person Blackhex; 29.12.2012
comment
каков наиболее распространенный подход, который не вызовет каких-либо заметных странных скачков кадров? - person android developer; 29.12.2012
comment
Я предполагаю, что первый достаточно хорош и самый простой, поэтому он будет наиболее часто используемым. См. отредактированный пример, как его использует PlayN. - person Blackhex; 29.12.2012
comment
я понимаю. Спасибо . есть ли пример кода, который демонстрирует это с помощью libgdx? - person android developer; 29.12.2012
comment
Я не понимаю, как вычисление какой-то интерполяции в потоке рендеринга отличается от подхода к выполнению всей логики обновления в потоке рендеринга. - person user1578101; 30.12.2012
comment
Проблема, насколько я понимаю сейчас, заключается в том, что с логикой обновления в другом потоке состояние объекта при запуске средства визуализации не обязательно будет актуальным до этой миллисекунды. Обновление могло произойти за 1 мс до розыгрыша или около 15 мс. В следующем кадре, учитывая любое отклонение от Thread.sleep или просто всплеск в любом потоке, время между обновлением и отрисовкой может быть другим. Любые дальнейшие идеи по этому поводу? - person user1578101; 30.12.2012
comment
Я также имею дополнительное значение, когда поток обновления обновляется через регулярные промежутки времени (например, с использованием физического движка), а поток рендеринга рендерится так быстро, как только может (без сна). - person Blackhex; 30.12.2012