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

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

Нас интересует функция Lerp в Unity3D. А конкретнее, метод Lerp класса Vector3D. Впрочем, все «лерпы» в Юнити работают более или менее идентично. Итак, классический образчик использования Lerp в Юнити, фигурирующий во многих туториалах:

...
Vector3 destinationPoint;
float smoothing;
...
void Update()
{
//Плавное "скольжение" из одной точки в другую
transform.position = Vector3.Lerp (transform.position, destinationPoint, smoothing * Time.deltaTime);
}

По идее, в данном коде объект, к которому относится скрипт, вместо моментального переноса в точку destinationPoint плавно туда передвигается. Код — по сути — простой. Однако, в его простоте есть определенные нюансы.

Первый момент возник, когда один человек, кинув взгляд, сказал, что этот код линейно интерполирует положение объекта из текущего в destinationPoint.

Собственно, функция Lerp — это и правда попросту линейная интерполяция. Но это сама функция. А вот действие кода вышеупомянутого метода Update уже не столь очевидно. При его применении видно, что объект сдвигается в нужном направлении, и по ходу дела замедляется. В случае же «истинной» линейной интерполяции объект должен начать движение, перемещаться с фиксированной скоростью, затем резко замереть. Именно так происходит, если обращаться к вариантам использования Lerp не из туториалов, а из официальной справки по скриптам (конкретно, по методу Lerp). А вот упомянутый код ведет себя не так.

Второй момент возник, когда другой человек сказал, что это вообще работать не должно. Функция Lerp должна принимать параметр (назовем его традиционно t), который меняется от 0 до 1. Соответственно, при нуле — положение соответствует начальной точке пути; при единице — конечной. Стало быть, когда параметр пробегает значения от 0 до 1 происходит перемещение от начальной точки к конечной. Но ведь в вышеуказанном коде параметр t практически не меняется! Переменная smoothing, задающая «гладкость» движения — фиксированна. deltaTime меняется в случайных пределах, но грубо-примерно находится на одном уровне; для постоянной частоты кадров она будет постоянна. Таким образом можно считать, что параметр t вообще не меняется, а значит и не будет меняться положение точки. И движения не будет.

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

Почему так происходит? Потому что положение начальной точки интерполяции меняется при каждом обновлении.

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

image

В результате выполнения

Vector3.Lerp (transform.position, destinationPoint, 0.25);

получаем точку на расстоянии четверти от начала. Эта-то точка и становится новым положением объекта, и при следующем вызове метода Update() плясать мы будем уже от нее. Это к вопросу: почему это все же работает.

image

Новое расстояние d' теперь меньше (поскольку объект придвинулся). Стало быть, четверть от этого расстояния будет тоже меньше. В итоге, объект сдвинется еще ближе к точке назначения, но уже на меньшее расстояние.

image

При следующем обновлении — объект пройдет еще меньшее расстояние.

Это уже к вопросу: почему используем линейную интерполяцию, а получаем нелинейное движение. Чем ближе объект к точке назначения, тем меньшее расстояние ему до него остается, но тем и меньше шаг, который он сделает, подобно объекту из апорий Зенона.

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

Такое использование функции Lerp определенно имеет право на жизнь, но понятности оно не дает. Люди, услышавшие словосочетание «линейная интерполяция» часто предполагают другое поведение. Кроме того, есть много интересных фишек, позволяющих превратить интерполяцию из линейной в другую. Основаны они, обычно, на изменении параметра t. Фокус в том, что при использовании указанного примера все эти наработки вести себя будут совсем не так, как ожидалось. Я полагаю, разработчики Unity3D сами-то понимают функционирование подобного кода, но не объясняют таких нюансов, видимо не желая нагружать лишней (по их мнению) информацией.

Привычно функция Lerp и подобные ей используется для получения ряда промежуточных значений (как правило от начального до конечного). В данном же коде она нужна для получения одной конкретной точки: при каждом вызове Update() она находит значение точки, делящей отрезок пути в заданном отношении.

Еще звучал интересный вопрос, который я не совсем понял: коль скоро значение параметра интерполяции не меняется, зачем там вообще deltaTime? Ну, собственно, хорошая практика кодинга в Unity предполагает независимость от частоты кадров. Разумеется, при нестабильности частоты кадров разницу в поведении кода, что с умножением на Time.deltaTime, что без оного — на глаз не заметно. Но факт есть факт.

Другой вопрос, который уже задал я сам себе: зачем тогда умножать на Time.deltaTime в методе FixedUpdate()? Ведь разработчики уверяют, что время, проходящее между вызовами этого метода строго фиксировано (почти… см. ниже). Однако, в туториалах код, подобный вышеупомянутому, попадается и в методе FixedUpdate() (например, тут).

Тут возможно несколько вариантов: возможно, ведущие этой обучалки, привыкшие к данному шаблону, попросту вбили его не задумываясь. Либо же гарантировали идентичность результатов выполнения кода на случай, если по какой-либо причине частота обновления физики (вызовов FixedUpdate()) будет изменена. А может просто решили не добавлять «магических констант», а заодно обеспечить определенную совместимость (и переносимость) кода между методами Update() и FixedUpdate(), поскольку в противном случае пришлось бы иметь отдельные smoothing для первого и для второго метода.

Вообще, с этими временами обновления тоже не все гладко. Для FixedUpdate() заведена своя переменная fixedDeltaTime, которая, судя по названию, должна давать время между его вызовами… Но нет же, сами же разработчики рекомендуют и в FixedUpdate() и в Update() использовать deltaTime, поскольку частота вызовов FixedUpdate() фиксированная-фиксированная, да не очень.

Так или иначе, итог.

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

Вообще, я предпочитаю функцию SmoothDamp, которая, хоть и требует хранения дополнительной переменной, ведет себя более предсказуемо (позволяя, например, задать примерное время «прибытия» объекта на заданное место).

Всем спасибо за внимание.
Поделиться с друзьями
-->

Комментарии (21)


  1. norlin
    20.12.2016 15:19

    ИМХО, в данном примере, это баг в реализации функции Update, имеет мало отношения к самой функции Lerp.


    1. aikixd
      20.12.2016 15:57

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


      1. norlin
        20.12.2016 17:29

        Ну именно это и есть баг – в широком смысле, как "некорректное поведение". Для корректного поведения там надо не дельту между кадрами в качестве параметра передавать, а, хотя бы, дельту с момента начала перемещения (которое, да, надо будет где-то хранить).


  1. byme
    20.12.2016 15:32

    А почему не воспользоваться Transform.Translate? Он идельно подход под этот случай.


    1. kosmos89
      20.12.2016 18:47

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


  1. Durimar123
    20.12.2016 15:53

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


  1. Tutanhomon
    20.12.2016 15:55

    Другой вопрос, который уже задал я сам себе: зачем тогда умножать на Time.deltaTime в методе FixedUpdate()?
    Потому, что интервал вызова FixedUpdate задается в настройках Юнити, и не является константов в глобальном смысле слова.


  1. Tutanhomon
    20.12.2016 15:58
    +5

    Лерп абсолюнто имеет право на жизнь. Просто надо интерполировать не между текущей и конечной точками, а сначала запомнить начальную (которая в начале пути равна текущей) и интерполировать между начальной и конечной, тогда движение будет линейным.
    У меня больше другое вызывает вопросы — зачем для Lerp статья на хабре? Приведенное в статье использование оной не является багой или фичей, о которой никто не знает — а всего лишь один из вариантов реализации, и поведение его вполне объяснимо и предсказуемо. Так что я немного удивился.


    1. norlin
      20.12.2016 17:31

      В данном примере lerp по дельте между кадрами между начальной и конечной точкой не даст вообще никакого движения. Максимум – получится дёрганье на месте.


      1. Tutanhomon
        20.12.2016 17:36

        Vector3 destinationPoint;
        Vector3 startPoint;
        float time;
        ...
        void Start()
        {
            startPoint = transform.position;
        }
        
        void Update()
        {
            transform.position = Vector3.Lerp (startPoint, destinationPoint, time);
            time += Time.deltaTime;
            if(time > 1)
            {
                time = 0;
            }
        }
        


        как вариант. будет клавно идти от начала до конца, каждый раз возвращаясь в начало.


        1. norlin
          20.12.2016 17:50

          time += Time.deltaTime;

          я вот об этом как раз и говорил :-)
          В примере из статьи напрямую Time.deltaTime используется в качестве параметра.


          1. Tutanhomon
            20.12.2016 18:06

            Или использовать Time.time. Но за 8 лет работы с Юнити подобными конструкциями приходилось пользоваться разве что в самом начале, сейчас это в основном iTween, LeenTween и, конечно, корутины. Вышеприведенный код больше образовательного формата.


      1. cbr2002hell
        20.12.2016 19:08
        -2

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


        1. norlin
          20.12.2016 19:14
          +1

          Ваш пример работает только потому, что на каждый Update меняется начальная точка лерпа, как вы и написали в посте. А Tutanhomon в комментарии выше предложил начальную точку зафиксировать. В этом случае движения как раз не будет.


          1. cbr2002hell
            20.12.2016 19:22

            А, простите. Думал, Вы говорите про пример из статьи. Прошу прощения.


    1. cbr2002hell
      20.12.2016 19:16
      -1

      Как показывает моя практика общения (и комментарии к статье) — поведение не особо предсказуемо. Собственно, потому и статья.


  1. Igor_Sib
    21.12.2016 06:55

    Делать нелинейную интерполяцию лучше твинером (HOTween или DOTween например).


  1. lis355
    21.12.2016 10:54

    "популярный шаблон ее использования"
    Что делает функция Lerp можно понять (да и надо бы знать) по первой ссылке в гугле. Как программист, я бы сразу задал себе 2 вопроса:


    • Зачем передавать каждый раз текущие координаты?
    • Зачем передавать Time.deltaTime?

    По мне так самый адекватный ответ — сломать себе мозг и получить трудно представимое поведение. Как только узнал про такую функцию (еще где-то до юнити), юзал ее так, как она и предполагает — как написал в комментарии Tutanhomon.
    Поэтому я бы с ПОПУЛЯРНОСТЬЮ такого использования бы поспорил. Имхо, адекватные люди так не пишут код.


    Вот кстати, если вы такую статью большую написали, сделали бы график нелинейного движения, написали бы закон изменения координат) Исследование настоящее)


    1. cbr2002hell
      21.12.2016 19:14
      +1

      Касательно популярности. Собственно, практически _все_ туториалы с официального сайта (https://unity3d.com) используют данный шаблон.

      Что делает функция Lerp _сама по себе_ — это как раз понятно. И именно эта понятность и затрудняет понимание вышеприведенного кода, где результат уже не линеен. Именно поэтому и статья.

      А «график» приведенного движения довольно прост: при стремлении временного интервала к нулю получаем классическую exp(-k*t), где k — некий коэффициент. Собственно, это видно из базового разностного уравнения.

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


  1. Chaliapine
    21.12.2016 19:04

    Для FixedUpdate() заведена своя переменная fixedDeltaTime, которая, судя по названию, должна давать время между его вызовами… Но нет же, сами же разработчики рекомендуют и в FixedUpdate() и в Update() использовать deltaTime, поскольку частота вызовов FixedUpdate() фиксированная-фиксированная, да не очень.

    Time.deltaTime — время в секундах затраченное на отображение последнего фрейма, после отображения предпоследнего.
    Time.fixedDeltaTime — интервал в секундах, через каждый промежуток fixedDeltaTime вызывается обновление физики и другие фиксированные апдейты, как, например, fixedUpdate()

    Также есть момент, что deltaTime вернет вроде бы fixedDeltaTime, если будет использован в fixedUpdate()
    Промежутки между вызовами fixedUpdate() фиксированы, но их можно менять для увеличения/уменьшения точности расчета физики.

    Еще звучал интересный вопрос, который я не совсем понял: коль скоро значение параметра интерполяции не меняется, зачем там вообще deltaTime?

    Для того, чтобы скорость игры оставалась постоянной для любой машины на которой запущена игра.
    Пример: если нет deltaTime, то при 100fps и 10fps объект переместиться за разное реальное время, так как в одном случае метод выполнится 100 раз за секунду, а в другом лишь 10.


    1. cbr2002hell
      21.12.2016 19:20

      С deltaTime и fixedDeltaTime в их теоретическом значении — понятно. Просто имеется рекомендация разработчиков использовать и в FixedUpdate(), и в Update() — deltaTime. Что несколько смущает. Так как автоматически означает рекомендацию не использовать fixedDeltaTime.

      «Еще звучал интересный вопрос, который я не совсем понял: коль скоро значение параметра интерполяции не меняется, зачем там вообще deltaTime?»
      Я не понял вопрос человека, а не ответ. Который, в свою очередь, очевиден.