Очень популярный способ реализации игрового цикла выглядит так:
while (playing) {
advance state by one frame
render the new frame
sleep until it’s time to do the next frame
}
С этим есть несколько проблем, наиболее фундаментальной из которых является идея о том, что игра может определять, что такое «фрейм». Различные дисплеи будут обновляться с разной скоростью, и эта скорость может меняться со временем. Если вы генерируете кадры быстрее, чем дисплей может их отобразить, вам придется время от времени отбрасывать один из них. Если вы генерируете их слишком медленно, SurfaceFlinger периодически не сможет найти новый буфер для получения и повторно покажет предыдущий кадр. Обе эти ситуации могут вызвать видимые сбои.
Что вам нужно сделать, так это подобрать частоту кадров дисплея и изменить состояние игры в зависимости от того, сколько времени прошло с момента предыдущего кадра. Есть несколько способов сделать это:
- Используйте библиотеку Android Frame Pacing (рекомендуется)
- Заполните BufferQueue полностью и положитесь на противодавление «буферов подкачки».
- Используйте хореографа (API 16+)
Библиотека синхронизации кадров Android
Информацию об использовании этой библиотеки см. в разделе «Достижение правильной синхронизации кадров» .
Наполнение очереди
Это очень легко реализовать: просто замените буферы как можно быстрее. В ранних версиях Android это могло привести к штрафу, когда SurfaceView#lockCanvas()
переводил вас в спящий режим на 100 мс. Теперь он управляется BufferQueue, и BufferQueue очищается настолько быстро, насколько это возможно с помощью SurfaceFlinger.
Один из примеров такого подхода можно увидеть в Android Breakout . Он использует GLSurfaceView, который работает в цикле, который вызывает обратный вызов приложения onDrawFrame(), а затем меняет местами буфер. Если BufferQueue заполнен, вызов eglSwapBuffers()
будет ждать, пока буфер не станет доступным. Буферы становятся доступными, когда SurfaceFlinger освобождает их, что происходит после получения нового для отображения. Поскольку это происходит при VSYNC, время цикла отрисовки будет соответствовать частоте обновления. По большей части.
С этим подходом есть несколько проблем. Во-первых, приложение привязано к активности SurfaceFlinger, которая будет занимать разное количество времени в зависимости от объема работы и борьбы за процессорное время с другими процессами. Поскольку состояние вашей игры меняется в зависимости от времени между заменами буфера, ваша анимация не будет обновляться с постоянной скоростью. Однако при работе со скоростью 60 кадров в секунду, когда несоответствия усредняются с течением времени, вы, вероятно, не заметите неровностей.
Во-вторых, первые несколько замен буферов будут происходить очень быстро, поскольку BufferQueue еще не заполнен. Вычисленное время между кадрами будет близко к нулю, поэтому игра сгенерирует несколько кадров, в которых ничего не происходит. В такой игре, как Breakout, которая обновляет экран при каждом обновлении, очередь всегда заполнена, за исключением случаев, когда игра запускается впервые (или не находится на паузе), поэтому эффект незаметен. В игре, которая время от времени приостанавливает анимацию, а затем возвращается в максимально быстрый режим, могут возникать странные сбои.
Хореограф
Choreographer позволяет вам установить обратный вызов, который сработает при следующем VSYNC. Фактическое время VSYNC передается в качестве аргумента. Таким образом, даже если ваше приложение не просыпается сразу, у вас все равно будет точное представление о том, когда начался период обновления дисплея. Использование этого значения вместо текущего времени дает согласованный источник времени для логики обновления состояния игры.
К сожалению, тот факт, что вы получаете обратный вызов после каждого VSYNC, не гарантирует, что ваш обратный вызов будет выполнен своевременно или что вы сможете отреагировать на него достаточно быстро. Вашему приложению необходимо будет обнаруживать ситуации, когда оно отстает, и вручную пропускать кадры.
Примером этого является действие «Приложение Record GL» в Grafika. На некоторых устройствах (например, Nexus 4 и Nexus 5) при активности начнут пропадать кадры, если вы просто сидите и смотрите. Рендеринг GL тривиален, но иногда элементы представления перерисовываются, а этап измерения/макета может занять очень много времени, если устройство перешло в режим пониженного энергопотребления. (По данным systrace, после замедления тактовой частоты на Android 4.4 требуется 28 мс вместо 6 мс. Если вы проводите пальцем по экрану, он думает, что вы взаимодействуете с действием, поэтому тактовая частота остается высокой, и вы никогда не упадете рамка.)
Простое решение заключалось в удалении кадра в обратном вызове Choreographer, если текущее время превышает N миллисекунд после времени VSYNC. В идеале значение N определяется на основе ранее наблюдаемых интервалов VSYNC. Например, если период обновления составляет 16,7 мс (60 кадров в секунду), вы можете пропустить кадр, если опоздаете более чем на 15 мс.
Если вы посмотрите, как работает приложение Record GL, вы увидите увеличение счетчика пропущенных кадров и даже увидите вспышку красного цвета на рамке при пропадании кадров. Однако, если у вас не очень хорошее зрение, вы не заметите заикания анимации. При частоте 60 кадров в секунду приложение может пропустить случайный кадр, и никто этого не заметит, пока анимация продолжает продвигаться с постоянной скоростью. Насколько вам это сойдет с рук, в некоторой степени зависит от того, что вы рисуете, характеристик дисплея и того, насколько хорошо человек, использующий приложение, обнаруживает рывки.
Управление потоками
Вообще говоря, если вы выполняете рендеринг на SurfaceView, GLSurfaceView или TextureView, вам нужно выполнять этот рендеринг в выделенном потоке. Никогда не делайте в потоке пользовательского интерфейса никакой «тяжелой работы» или чего-либо, что занимает неопределенное количество времени. Вместо этого создайте для игры два потока: игровой поток и поток рендеринга. Дополнительную информацию см. в разделе «Улучшите производительность игры» .
Breakout и «Приложение Record GL» используют выделенные потоки рендеринга, а также обновляют состояние анимации в этом потоке. Это разумный подход, если состояние игры можно быстро обновить.
В других играх игровая логика и рендеринг полностью разделены. Если бы у вас была простая игра, которая ничего не делала, кроме перемещения блока каждые 100 мс, вы могли бы создать специальный поток, который бы делал это:
run() {
Thread.sleep(100);
synchronized (mLock) {
moveBlock();
}
}
(Возможно, вы захотите определить время сна на основе фиксированных часов, чтобы предотвратить дрейф — функция Sleep() не является абсолютно согласованной, а функция moveBlock() занимает ненулевое количество времени — но суть вы поняли.)
Когда код отрисовки просыпается, он просто захватывает блокировку, получает текущую позицию блока, снимает блокировку и рисует. Вместо дробного перемещения, основанного на разнице между кадрами, у вас просто есть один поток, который перемещает объекты, и другой поток, который рисует объекты там, где они находятся, когда начинается рисование.
Для сцены любой сложности вам нужно создать список предстоящих событий, отсортированный по времени пробуждения, и спать до тех пор, пока не наступит следующее событие, но это та же идея.