Как интерполяция на самом деле работает, чтобы сгладить движение объекта?

10

За последние 8 месяцев я задавал несколько похожих вопросов без особой радости, поэтому я собираюсь сделать вопрос более общим.

У меня есть игра для Android, которая является OpenGL ES 2.0. внутри него у меня есть следующий цикл игры:

Мой цикл работает по принципу фиксированного временного шага (dt = 1 / ticksPerSecond )

loops=0;

    while(System.currentTimeMillis() > nextGameTick && loops < maxFrameskip){

        updateLogic(dt);
        nextGameTick+=skipTicks;
        timeCorrection += (1000d/ticksPerSecond) % 1;
        nextGameTick+=timeCorrection;
        timeCorrection %=1;
        loops++;

    }

    render();   

Моя интеграция работает так:

sprite.posX+=sprite.xVel*dt;
sprite.posXDrawAt=sprite.posX*width;

Теперь все работает так, как мне бы хотелось. Я могу указать, что я хотел бы, чтобы объект переместился на определенное расстояние (скажем, ширину экрана) за 2,5 секунды, и это будет сделано именно так. Также из-за пропуска кадров, который я допускаю в своем игровом цикле, я могу сделать это практически на любом устройстве, и это всегда будет занимать 2,5 секунды.

проблема

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

Я все еще не уверен, почему кадр пропускает, но я хотел бы отметить, что это не имеет ничего общего с низкой производительностью , я вернул код обратно к 1 крошечному спрайту и никакой логике (кроме логики, необходимой для переместить спрайт) и я все равно получаю пропущенные кадры. И это на планшете Google Nexus 10 (и, как уже упоминалось выше, мне нужно пропустить кадр, чтобы скорость в любом случае оставалась неизменной).

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

Используя один метод, я смог заставить вещи двигаться гладко, но это было неработоспособно, потому что это испортило мое столкновение. Я могу предвидеть ту же проблему с любым подобным методом, потому что интерполяция передается (и действует внутри) в метод рендеринга - во время рендеринга. Таким образом, если Collision исправляет положение (персонаж теперь стоит прямо возле стены), то средство визуализации может изменить свое положение и нарисовать его в стене.

Так что я действительно запутался. Люди говорят, что вы никогда не должны изменять положение объекта в методе рендеринга, но все примеры в Интернете показывают это.

Поэтому я прошу толчок в правильном направлении, пожалуйста, не делайте ссылки на популярные статьи игрового цикла (deWitters, Fix your timetep и т. Д.), Поскольку я читал их несколько раз . Я не прошу никого писать мой код для меня. Просто объясните, пожалуйста, простыми словами, как на самом деле работает интерполяция с некоторыми примерами. Затем я пойду и попытаюсь интегрировать любые идеи в мой код и задам более конкретные вопросы, если это будет необходимо. (Я уверен, что это проблема, с которой многие люди борются).

редактировать

Некоторая дополнительная информация - переменные, используемые в игровом цикле.

private long nextGameTick = System.currentTimeMillis();
//loop counter
private int loops;
//Amount of frames that we will allow app to skip before logic is affected
private final int maxFrameskip = 5;                         
//Game updates per second
final int ticksPerSecond = 60;
//Amount of time each update should take        
private final int skipTicks = (1000 / ticksPerSecond);
float dt = 1f/ticksPerSecond;
private double timeCorrection;
BungleBonce
источник
И причина для понижения ...................?
BungleBonce
1
Невозможно сказать иногда. Это похоже на то, что нужно иметь хороший вопрос при попытке решить проблему. Краткий фрагмент кода, объяснения того, что вы пробовали, попытки исследования и четкое объяснение, в чем заключается ваша проблема и что вам нужно знать.
Джесси Дорси
Я не был твоим отрицательным голосом, но, пожалуйста, уточни одну часть. Вы говорите, что графика заикается, когда кадр пропущен. Это кажется очевидным утверждением (пропущен кадр, похоже, что кадр пропущен). Так что вы можете лучше объяснить пропуск? Что-то странное происходит? Если нет, это может быть неразрешимой проблемой, потому что вы не можете получить плавное движение, если частота кадров падает.
Сет Баттин
Спасибо, Noctrine, меня просто раздражает, когда люди понижают голос, не оставляя объяснений. @SethBattin, извините, да, конечно, вы правы, пропуск кадров вызывает рывки, однако какая-то интерполяция должна разобраться, как я уже говорил выше, у меня был некоторый (но ограниченный) успех. Если я ошибаюсь, то, наверное, вопрос будет в том, как я могу заставить его работать плавно и с одинаковой скоростью на разных устройствах?
BungleBonce
4
Тщательно перечитайте эти документы. Они фактически не изменяют местоположение объекта в методе рендеринга. Они только изменяют видимое местоположение метода на основе его последней позиции и текущей позиции на основе того, сколько времени прошло.
AttackingHobo

Ответы:

5

Есть две вещи, которые очень важны для придания движению гладкости, во-первых, очевидно, что то, что вы отображаете, должно соответствовать ожидаемому состоянию во время представления кадра пользователю, во-вторых, вам нужно представить кадры пользователю. с относительно фиксированным интервалом. Представление кадра в момент времени T + 10 мс, затем другого в момент времени T + 30 мс, а затем другого в момент времени T + 40 мс, покажется пользователю, что он судит, даже если то, что фактически показано для тех времен, является правильным в соответствии с моделированием.

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

То, что вам, вероятно, нужно, это ожидание непосредственно перед рендерингом, которое гарантирует, что вы начнете рендеринг только в начале интервала рендеринга. В идеале это должно быть адаптивно: если вам потребовалось слишком много времени для обновления / рендеринга, а начало интервала уже прошло, вы должны выполнить рендеринг немедленно, но также увеличьте длину интервала, пока вы не сможете последовательно рендерить и обновлять, и при этом добираться до следующий рендер до окончания интервала. Если у вас есть много свободного времени, то вы можете медленно уменьшить интервал (то есть увеличить частоту кадров), чтобы снова ускорить рендеринг.

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

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

Итак, чтобы поместить его в цикл псевдокода, я думаю, вам нужно что-то вроде:

InitialiseWorldState();

previousTime = currentTime = 0.0;
renderInterval = 1.0 / 60.0; //A nice high starting interval

subFrameProportion = 1.0; //100% currentFrame, 0% previousFrame

while (true)
{
    frameStart = ActualTime();

    //Render the world state as if it was some proportion 
    // between previousTime and currentTime
    // E.g. if subFrameProportion is 0.5, previousTime is 0.1 and 
    // currentTime is 0.2, then we actually want to render the state
    // as it would be at time 0.15. We'd do that by interpolating 
    // between movingObject.previousPosition and movingObject.currentPosition
    // with a lerp parameter of 0.5
    Render(subFrameProportion); 

    //Check we've not taken too long and missed our render interval
    frameTime = ActualTime() - frameStart;
    if (frameTime > renderInterval)
    {
        renderInterval = frameTime * 1.2f; //Give us a more reasonable render interval that we actually have a chance of hitting
    }

    expectedFrameEnd = frameStart + renderInterval;

    //Loop until it's time to render the next frame
    while (ActualTime() < expectedFrameEnd)
    {
        //step the simulation forward until it has moved just beyond the frame end
        if (previousTime < expectedFrameEnd) &&
            currentTime >= expectedFrameEnd)
        {
            previousTime = currentTime;

            Update();
            currentTime += fixedTimeStep;

            //After the update, all objects will be in the position they should be for
            // currentTime, **but** they also need to remember where they were before,
            // so that the rendering can draw them somewhere between previousTime and
            //  currentTime

            //Check again we've not taken too long and missed our render interval
            frameTime = ActualTime() - frameStart;
            if (frameTime > renderInterval)
            {
                renderInterval = frameTime * 1.2f; //Give us a more reasonable render interval that we actually have a chance of hitting
                expectedFrameEnd = frameStart + renderInterval
            }
        }
        else
        {
            //We've brought the simulation to just after the next time
            // we expect to render, so we just want to wait.
            // Ideally sleep or spin in a tight loop while waiting.
            timeTillFrameEnd = expectedFrameEnd - ActualTime();
            sleep(timeTillFrameEnd);
        }
    }

    //How far between update timesteps (i.e. previousTime and currentTime)
    // will we be at the end of the frame when we start the next render?
    subFrameProportion = (expectedFrameEnd - previousTime) / (currentTime - previousTime);
}

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

class MovingObject
{
    Vector velocity;
    Vector previousPosition;
    Vector currentPosition;

    Initialise(startPosition, startVelocity)
    {
        currentPosition = startPosition; // position at time 0
        velocity = startVelocity;
        //ignore previousPosition because we should never render before time 0
    }

    Update()
    {
        previousPosition = currentPosition;
        currentPosition += velocity * fixedTimeStep;
    }

    Render(subFrameProportion)
    {
        Vector actualPosition = 
            Lerp(previousPosition, currentPosition, subFrameProportion);
        RenderAt(actualPosition);
    }
}

И давайте выложим временную шкалу в миллисекундах, говоря, что рендеринг занимает 3 мсек, обновление занимает 1 мс, ваш временной шаг обновления фиксируется на 5 мс, а ваш временной интервал рендеринга начинается (и остается) с 16 мс [60 Гц].

0   1   2   3   4   5   6   7   8   9   10  11  12  13  14  15  16  17  18  19  20  21  22  23  24  25  26  27  28  29  30  31  32  33
R0          U5  U10 U15 U20 W16                                 R16         U25 U30 U35 W32                                 R32
  1. Сначала мы инициализируем в момент времени 0 (поэтому currentTime = 0)
  2. Мы рендерим с пропорцией 1,0 (100% currentTime), которая будет рисовать мир во время 0
  3. Когда это заканчивается, фактическое время равно 3, и мы не ожидаем, что кадр закончится до 16, поэтому нам нужно запустить некоторые обновления
  4. T + 3: мы обновляем с 0 до 5 (поэтому currentTime = 5, previousTime = 0)
  5. T + 4: еще до конца кадра, поэтому мы обновляем с 5 до 10
  6. T + 5: еще до конца кадра, поэтому мы обновляем с 10 до 15
  7. T + 6: еще до конца кадра, поэтому мы обновляем с 15 до 20
  8. T + 7: еще до конца кадра, но currentTime находится сразу за концом кадра. Мы не хотим имитировать дальше, потому что это подтолкнет нас к тому времени, когда мы в следующий раз захотим отрендерить. Вместо этого мы спокойно ждем следующего интервала рендеринга (16)
  9. T + 16: время рендеринга снова. previousTime - 15, currentTime - 20. Так что, если мы хотим сделать рендеринг в T + 16, мы пройдем 1 мс через 5 мс. Таким образом, мы находимся на 20% пути через кадр (пропорция = 0,2). Когда мы рендерим, мы рисуем объекты на 20% пути между их предыдущей и текущей позициями.
  10. Вернитесь к 3. и продолжайте до бесконечности.

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

MrCranky
источник
NB: псевдокод слаб в двух отношениях. Во-первых, он не улавливает случай смерти-спирали (для обновления требуется больше времени, чем fixedTimeStep, что означает, что симуляция отстает еще дальше, фактически бесконечный цикл), во-вторых, renderInterval никогда не сокращается снова. На практике вы хотите сразу увеличить renderInterval, а затем постепенно сокращать его, насколько это возможно, с точностью до некоторого допуска фактического времени кадра. В противном случае одно плохое / длинное обновление навсегда утомит вас низкой частотой кадров.
MrCranky
Спасибо за это @MrCranky, я действительно целую вечность боролся за то, как «ограничить» рендеринг в моем цикле! Просто не мог понять, как это сделать, и подумал, может ли это быть одной из проблем. Я прочту это и прочту ваши предложения, сообщу!
Еще
Спасибо @MrCranky, хорошо, я прочитал и перечитал твой ответ, но я не могу понять его :-( Я пытался реализовать его, но он просто дал мне пустой экран. Действительно борюсь с этим. PreviousFrame и currentFrame, я полагаю относится к предыдущим и текущим позициям моих движущихся объектов? А как насчет строки "currentFrame = Update ();" - я не получаю эту строку, означает ли это вызов update (), так как я не могу увидеть, где еще я вызываю обновление? Или это просто означает, что для currentFrame (position) задано новое значение?
Еще
Да, эффективно. Причина, по которой я добавил в качестве возвращаемых значений Update и InitialiseWorldState значения PreviousFrame и currentFrame, заключается в том, что для того, чтобы при рендеринге можно было нарисовать мир, поскольку он находится на полпути между двумя фиксированными шагами обновления, необходимо иметь не только текущую позицию каждого элемента. объект, который вы хотите нарисовать, но и их предыдущие позиции. Вы можете сделать так, чтобы каждый объект сохранял оба значения внутри, что становится громоздким.
MrCranky
Но также возможно (но гораздо сложнее) сконструировать вещи так, чтобы вся информация о состоянии, необходимая для представления текущего состояния мира в момент времени T, содержалась в одном объекте. Концептуально это намного чище, когда объясняется, какая информация присутствует в системе, поскольку вы можете рассматривать состояние кадра как нечто, созданное на этапе обновления, а сохранение предыдущего кадра означает лишь сохранение еще одного из этих объектов состояния кадра. Тем не менее, я мог бы переписать ответ, чтобы он был немного более похожим на то, как вы на самом деле, вероятно, реализуете его.
MrCranky
3

То, что все говорили тебе, правильно. Никогда не обновляйте позицию симуляции вашего спрайта в логике рендеринга.

Думайте об этом так, ваш спрайт имеет 2 позиции; где симуляция говорит, что он на момент последнего обновления симуляции, и где отображается спрайт. Это две совершенно разные координаты.

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

Помимо этого, у вас, кажется, есть хорошее понимание. Надеюсь это поможет.

Уильям Моррисон
источник
Отлично @WilliamMorrison - спасибо за подтверждение этого, я никогда не был на 100% уверен, что это так, теперь я думаю, что я нахожусь на пути к тому, чтобы это работало в некоторой степени - ура!
BungleBonce
Просто любопытно, @WilliamMorrison, используя эти одноразовые координаты, как можно было бы смягчить проблему рисования спрайтов «встроенными» или «чуть выше» других объектов - очевидный пример - быть твердыми объектами в 2D-игре. Придется ли вам запускать код столкновения во время рендеринга?
BungleBonce
В моих играх да, это то, что я делаю. Пожалуйста, будь лучше меня, не делай этого, это не лучшее решение. Это усложняет рендеринг кода логикой, которую он не должен использовать, и тратит процессор на избыточное обнаружение коллизий. Было бы лучше интерполировать между второй и последней позицией и текущей позицией. Это решает проблему, поскольку вы не экстраполируете на плохую позицию, но действительно усложняет ситуацию, когда вы делаете один шаг позади симуляции. Мне бы очень хотелось услышать ваше мнение, какой подход вы выбрали, и ваш опыт.
Уильям Моррисон
Да, это сложная проблема, чтобы решить. Я задал отдельный вопрос об этом здесь gamedev.stackexchange.com/questions/83230/… если вы хотите следить за этим или внести свой вклад. Теперь, что вы предложили в своем комментарии, я не делаю это уже? (Интерполировать между предыдущим и текущим кадром)?
BungleBonce
Не совсем. Вы на самом деле экстраполируете прямо сейчас. Вы берете самые последние данные из моделирования и экстраполируете, как эти данные выглядят после дробных временных шагов. Я предлагаю вам интерполировать между последней позицией симуляции и текущей позицией симуляции по долям временного шага для рендеринга. Рендеринг будет за симуляцией на 1 шаг. Это гарантирует, что вы никогда не будете визуализировать объект в состоянии, которое симуляция не проверила (т. Е. Снаряд не появится в стене, если симуляция не удалась.)
Уильям Моррисон