Рубрики
3D мир Программирование

OpenGL 20: загрузка .glTF ч.2 — базовые анимации

Внутреннее устройство и работа с файлами .glTF ч.2 — загрузка анимаций и применение их на сцене

Введение

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

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

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

Содержание заметки:

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

Анимации в трехмерной графике

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

Есть два основных типа трехмерной графики:

  • анимация ключевых кадров (keyframe animation) — фундаментальный метод анимации, заключающийся в предварительной установке ключевых точек-кадров на этапе моделирования, которые определяют значимые моменты движения или трансформации объекта;
  • процедурная анимация — алгоритмический метод анимации, который создается в реальном времени и используется для элементов, где детальное ключевое кадрирование было бы не реалистичным или не практичным (волосы, ткань, частицы).

Примечание: процедурная анимация может быть как самостоятельным подходом, так и комбинироваться в совокупности с разновидностями анимации ключевых кадров.

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

  • скелетная анимация (skeletal animation) — разновидность анимации ключевых кадров, где движение управляется через «скелет» (систему костей и сочленений) — положение костей влияет на связанные с ними вершины;
  • преобразование (морфинг, morph target animation) — включает в себя переход между различными предопределенными формами.

Существует несколько видов интерполяции:

  • константная (ступенчатая);
  • линейная;
  • по кривой Безье;
  • кубическая-сплайн интерполяция.

Константная интерполяция

Константная интерполяция, также известная как «ступенчатая» интерполяция (step interpolation), это метод, при котором значение остается неизменным до следующего ключевого кадра. В контексте анимации это означает, что объект остается в одном и том же состоянии до того момента, когда достигается следующий ключевой кадр, где он мгновенно переходит к новому состоянию. Данный вид интерполяции можно представить в виде:
Y(t) = \begin{cases} Y_1, & \text{если } T < t_2 \\ Y_2, & \text{если } T \geq t_2 \end{cases},
где T — время с момента t_1, t_1 — время точки 1, t_2 — время точки 2, Y(t) — интерполированная величина, Y_1 — величина точки 1, Y_2 — величина точки 2.

Пример константной интерполяции на примере кубика с тремя ключевыми кадрами (поворот на 0°, поворот на 45° и поворот на 90°) изображен на рисунке 1.

Рисунок 1 — Анимация переворачивающегося кубика с использованием константной интерполяции

Далее представлен интерактивный пример изменения величины:


Точка 1 Y (величина):
Точка 2 Y (величина):
Время интерполяции:

Линейная интерполяция

Рассмотрим линейную интерполяцию. Формула по которой вычисляется величина параметра:
Y(t) = (1 - t) Y_1 + t Y_2,
где t — относительный момент времени, Y(t) — интерполированная величина, Y_1 — величина точки 1, Y_2 — величина точки 2.
Данное выражение можно преобразовать через вынесение t «за скобки»:
Y(t) = Y_1 + t (Y_2 - Y_1)

Здесь и далее относительный момент времени:
t = \frac{T - t_1}{t_2 - t_1},
где T — время с момента t_1, t_1 — время точки 1, t_2 — время точки 2.

Пример линейной интерполяции на примере кубика с тремя ключевыми кадрами (поворот на 0°, поворот на 45° и поворот на 90°) изображен на рисунке 2.

Рисунок 2 — Анимация переворачивающегося кубика с использованием линейной интерполяции

Важное замечание: на рисунке 2 используется сферическая линейная интерполяция для кватерниона, описывающего поворот, которая рассматривается после интерактивного примера.

Далее представлен интерактивный пример изменения величины:


Точка 1 Y (величина):
Точка 2 Y (величина):
Время интерполяции:

В случае линейной интерполяции кватернионов могут возникать проблемы с рывками модели в процессе поворотов, что обусловлено «вырождением кватерниона» (приближением w-компоненты к нулю). Избежать данной ситуации можно используя сферическую линейную интерполяцию для кватернионов:
\text{SLERP}(q_1, q_2, t) = \frac{\sin((1 - t) \cdot \Omega) \cdot q_1 + \sin(t \cdot \Omega) \cdot q_2}{\sin(\Omega)},
где q_1 и q_2 — интерполируемые кватернионы, t — относительный момент времени, \Omega — угол между кватернионами, вычисляемый как \Omega = arccos(dot(q_1, q_2​)).

В GLM можно использовать готовые функции:

  • glm::lerp — для интерполяции векторов перемещения и масштабирования;
  • glm::slerp — для интерполяции кватернионов, описывающих поворот.

Пример:

pos = glm::lerp(pos1, pos2, t);
rot = glm::slerp(rot1, rot2, t);

Интерполяция с помощью кривых Безье

Интерполяция с помощью кривых Безье второго и третьего порядков является популярным методом в компьютерной графике для создания плавных и контролируемых кривых.

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

Важное примечание: кривая Безье первого порядка является прямой линией и в целом соответствует линейной интерполяции.

Интерполяция с помощью кривых Безье второго порядка вычисляется по следующей формуле:
вычисляется как:
Y(t) = (1 - t_b)^2 Y_1 + 2(1 - t_b)t_b Y_{c1} + t_b^2 Y_2,
где t_b — относительный момент времени, Y(t) — интерполированная величина, Y_1 — величина точки 1, Y_2 — величина точки 2, Y_{c1} — величина контрольной точки 1.

Для кривых Безье второго порядка положение контрольной точки на временной шкале влияет на движение анимации:
t_b = t ((1 - t) X_{c1} + t) ,
где X_{c1} — время контрольной точки 1 [0;1].

Далее представлен интерактивный пример изменения величины:


Точка 1 Y (величина):
Точка 2 Y (величина):
Контрольная точка 1
X (время):
Y (величина):
Время интерполяции:

Интерполяция с помощью кривых Безье третьего порядка основана на четырех точках и вычисляется как:
Y(t) = (1 - t_b)^3 Y_1 + 3(1 - t_b)^2 t_b Y_{c1} + 3(1 - t_b)t_b^2 Y_{c2} + t_b^3 Y_2,
где t_b — относительный момент времени, Y(t) — интерполированная величина, Y_1 — величина точки 1, Y_2 — величина точки 2, Y_{c1} — величина контрольной точки 1, Y_{c2} — величина контрольной точки 2.

Для кривых Безье третьего порядка положение контрольных точек на временной шкале влияет на движение анимации:
t_b = 3t(1 - t)^2 * X_{c1} + 3t^2(1 - t) * X_{c2} + t^3,
X_{c1} — время контрольной точки 1 [0;1], X_{c2} — время контрольной точки 2 [0;1].

Пример интерполяции по кривой Безье 3 порядка на примере кубика с тремя ключевыми кадрами (поворот на 0°, поворот на 45° и поворот на 90°) изображен на рисунке 3.

Рисунок 3 — Анимация переворачивающегося кубика с использованием интерполяции по кривой Безье 3 порядка

Далее представлен интерактивный пример изменения величины:


Точка 1 Y (величина):
Точка 2 Y (величина):
Контрольная точка 1
X (время):
Y (величина):
Контрольная точка 2
X (время):
Y (величина):
Время интерполяции:

Важное замечание: предустановки интерактивного примера не соответствуют анимированному рисунку.

Кубическая сплайн-интерполяция

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

Интерполируемая величина вычисляется по следующей формуле:
Y(t) = (2t^3 - 3t^2 + 1) Y_1 + (t^3 - 2t^2 + t) M_1 + (-2t^3 + 3t^2) Y_2 + (t^3 - t^2) M_2,
где t — относительный момент времени, Y(t) — интерполированная величина, Y_1 — величина точки 1, Y_2 — величина точки 2, M_1 — исходящая касательная (out-tangent) для точки Y_1, M_2 — входная касательная (in-tangent) для точки Y_1.

Далее представлен интерактивный пример изменения величины:


Y1 (величина):
Y2 (величина):
M1 (касательная):
M2 (касательная):
Время интерполяции (t):

Согласно стандарту: «программам-экспортерам» в формат glTF СЛЕДУЕТ позаботится о значениях, которые могут привести к некорректным кватернионам. Данная ситуация может произойти, если кватернионы имеют разный знак, что в свою очередь может указывать на вращение вокруг оси несколько раз. В таком случае необходимо определить «кратчайшее» расстояние между кватернионами путем скалярного произведения — если скалярное произведение меньше нуля, то следует поменять знак у кватерниона и его касательной. На рисунке 4 представлен результат работы алгоритма интерполяции без учета кратчайшего расстояния между кватернионами, а на рисунке 5 — результат работы с учетом кратчайшего расстояния.

Рисунок 4 — Проблема кубической-сплайн интерполяции без учета кратчайшего расстояния при повороте
Рисунок 5 — Правильная работа кубической-сплайн интерполяции с учетом кратчайшего расстояния при повороте

Примечание: на рисунках 4 и 5 используется один и тот же файл с анимированным кубиком, но расхождения в положении кубика обусловлены расхождением начала записи с экрана.

Помимо этого кватернион требуется нормировать перед применением итогового вращения к объекту на сцене.

Анимации в формате glTF

В рамках заметок анимированная модель сделана в Blender и экспортирована в формат glTF. В формате glTF временные метки записаны в секундах, что позволяет отвязать ключевые кадры от частоты кадров (FPS).

Формат glTF поддерживает следующие виды интерполяции:

  • ступенчатая (STEP);
  • линейная (LINEAR);
  • кубическая сплайн-интерполяция (CUBICSPLINE).

В предыдущей заметке формат glTF был рассмотрен не полностью — исключая часть по анимации. Список анимаций находится на уровне файла в блоке «animations«, где каждая анимация содержит в себе:

  • channels — каналы, которые объединяют информацию о цели анимации и средство доступа к данным:
    • sampler — индекс в списке animations.samplers,
    • target — цель анимации, которая включает в себя:
      • node — индекс узла,
      • path — анимируемый параметр;
  • samplers — совокупность индексов средств доступа к времени и значению ключевого кадра, а так же вида интерполяции:
    • input — индекс средства доступа (accessor) к массиву временных меток для ключевых кадров,
    • output — индекс средства доступа (accessor) к массиву значений, соответствующих кадрам из input,
    • interpolation — определяет метод интерполяции между значениями ключевых кадров, может отсутствовать (по умолчанию LINEAR).

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

Массив значений, соответствующий временным меткам, имеет различные типы хранимых данных в зависимости от анимируемого параметра. Соответствие анимируемых параметров и типов хранимых данных представлено в таблице 1.

Анимируемый параметрРазмер вектораТип данных компонентыОписание
translationglm::vec3floatВектор смещения (позиции) в формате XYZ
rotationglm::quat
(glm::vec4)
float,
signed byte normalized,
unsigned byte normalized,
signed short normalized,
unsigned short normalized
Кватернион поворота в формате XYZW
scaleglm::vec3floatВектор масштабирования в формате XYZ
weightsglm::vec1 (scalar)float,
signed byte normalized,
unsigned byte normalized,
signed short normalized,
unsigned short normalized
Веса для анимации преобразования (морфинг)
Таблица 1 — Соответствие анимируемых параметров и типов хранимых данных в массиве значений

Важное замечание: в Blender по умолчанию ось Z направлена вверх, что ведет к перемене мест координат осей Y и Z, а так же ось, направленная на камеру инвертирована.

Спецификация по формату glTF гласит: «Реализации ДОЛЖНЫ использовать следующие уравнения для декодирования реального значения с плавающей точкой из нормализованного целого числа и наоборот». Уравнения для конвертации представлены в таблице 2.

Тип компонентыint ➙ floatfloat ➙ int
signed bytef = max(c / 127.0, -1.0)c = round(f * 127.0)
unsigned bytef = c / 255.0c = round(f * 255.0)
signed shortf = max(c / 32767.0, -1.0)c = round(f * 32767.0)
unsigned shortf = c / 65535.0c = round(f * 65535.0)
Таблица 2 — Уравнения для конвертации типов компонент векторов

Для кубической сплайн-интерполяции в массиве значений дополнительно хранятся входящие и исходящие касательные (in-tangent, out-tangent) для каждого кадра в следующем порядке: in-tangent, value, out-tangentповекторно, не покомпонентно (один кадр — три вектора неразрывно подряд).

Класс анимации

Для хранения данных об анимации потребуются следующие перечисления:

  • INTERPOLIATION_TYPE — тип интерполяции в формате glTF:
    • STEP — константная интерполяция,
    • LINEAR — линейная интерполяция,
    • CUBICSPLINE — кубическая-сплайн интерполяция;
  • TARGET_PATH — изменяемый параметр у узла (Node):
    • POSITION — параметр местоположения (glm::vec3),
    • ROTATION — параметр поворота (glm::quat),
    • SCALE — параметр масштабирования (glm::vec3);
  • ANIM_ENDINGS — поведение при завершении шкалы ключевых кадров:
    • STOP — воспроизводится статично последний кадр,
    • TO_BEGIN — воспроизводится статично первый кадр,
    • CYCLED — зацикленное воспроизведение.

Создадим новый заголовочный файл include/Animation.h и добавим эти перечисления:

// Тип интерполяции
enum INTERPOLIATION_TYPE
{
    STEP,
    LINEAR,
    CUBICSPLINE
};  

// Анимируемый параметр
enum TARGET_PATH
{
    POSITION,
    ROTATION,
    SCALE
};

// Поведение при завершении шкалы ключевых кадров
enum ANIM_ENDINGS
{
    STOP,
    TO_BEGIN,
    CYCLED
};

Так как, в зависимости от анимируемого параметра (target_path), может потребоваться либо glm::vec3 (для позиции и масштаба), либо glm::quat (для поворота), добавим объединение (union), которое позволит унифицировать хранимые данные, а также не производить лишних вычислений для интерполяции позиции и масштабирования:

union PARAMETER_TYPE
{
    glm::quat quat;
    glm::vec3 vec3;
};

Важное замечание: union позволяет выиграть в вычислениях, исключив w-компоненту для позиции и масштабирования, но не позволяет сэкономить память, так как занимает место по самому большому объекту (glm::quat).

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

// Касательные для кубической-сплайн интерполяции
struct Tangents
{
    PARAMETER_TYPE in;
    PARAMETER_TYPE out;
};

Определим структуру для хранения данных по каналу анимации:

// Канал анимации
struct Channel
{
    void process(float dtime, ANIM_ENDINGS endings); // Выполнить анимацию для канала с учетом времени относительно начала

    class Node* target = NULL; // Анимируемый узел
    TARGET_PATH path = POSITION; // Анимируемый параметр
    
    INTERPOLIATION_TYPE interpolation = STEP; // Тип интерполяции
    std::vector<float> timestamps; // Временные метки !ОБЯЗАТЕЛЬНО ОТСОРТИРОВАНЫ ПО ВОЗРАСТАНИЮ!
    std::vector<PARAMETER_TYPE> values; // Данные по параметру (ИНДЕКС СООТВЕТСТВУЕТ ВРЕМЕННЫМ МЕТКАМ)
    std::vector<Tangents> tangents; // Касательные для CUBICSPLINE
};

В данной структуре хранится цель анимации в виде указателя на узел (Node), а так же её анимируемый параметр с определенным параметром интерполяции. Далее идут три динамических массива (std::vector) в которых хранятся:

  • временные метки — timestamps;
  • значения параметра — values;
  • значения касательных — tangents (только для кубической-сплайн интерполяции);

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

Так же у данной структуры есть метод Channel::process, который вычисляет текущее значение анимируемого параметра выбранным способом интерполяции на основании относительного времени и зацикленности, переданных в аргументах метода.

Добавим файл src/Animation.cpp, в котором будет реализация метода:

// Выполнить анимацию для канала с учетом времени относительно начала
void Channel::process(float dtime, ANIM_ENDINGS endings)
{
    // Если указатель на узел не пустой и есть ключевые кадры
    if (target && timestamps.size() && timestamps.size() == values.size())
    {

В случае, если анимация зациклена, то используем остаток от деления относительного времени (dtime — время от начала анимации) на общую продолжительность анимации:

        // Если анимация зациклена
        if (endings == CYCLED)
        {
            // Получаем общую длительность анимации
            float totalDuration = timestamps.back();
            // Обновляем dtime для зацикливания
            if (totalDuration > 0) 
            {
                dtime = fmod(dtime, totalDuration);
            }
        }

Важное замечание: проверка на наличие ключевых кадров производится в первом условном операторе данного метода.

На итоговое значение анимируемого параметра зависит от трех возможных ситуаций:

  • кадр только один;
  • относительное время превышает время последнего кадра;
  • есть два кадра между которыми находится относительное время.

В случае, если кадр только один, то можно легко применить его анимацию без дальнейших вычислений:

        // Итоговое значение параметра
        PARAMETER_TYPE parameterValue;
        // Если только один кадр, используем значение этого кадра
        if (timestamps.size() == 1) 
        {
            // Применяем значение к целевому узлу
            parameterValue = values[0];
        }
        else
        {

В противном случае необходимо рассмотреть две оставшиеся ситуации. Найдем индексы для интерполяции:

            // Поиск подходящих индексов для интерполяции
            size_t index1 = 0, index2 = 1;
            for (; index2 < timestamps.size(); ++index2) 
            {
                if (timestamps[index2] >= dtime) 
                {
                    break;
                }
                index1 = index2;
            }

Если время выходит за рамки последнего кадра, используем значение в зависимости от поведения при окончании анимации:

            if (index2 == timestamps.size()) 
            {
                if (endings == STOP)
                    parameterValue = values.back();
                else
                if (endings == TO_BEGIN)
                    parameterValue = values.front();
            }
            else
            {

В противном случае вычислим коэффициент интерполяции, используя относительное время и время двух выбранных кадров:

                // Вычисляем коэффициент интерполяции
                float t = (dtime - timestamps[index1]) / (timestamps[index2] - timestamps[index1]);

Теперь в зависимости от выбранного способа интерполяции возьмем значения кадров. Для константной интерполяции достаточно просто взять кадр под индексом 1:

                // Выбор метода интерполяции и интерполяция
                switch (interpolation) 
                {
                    case STEP:
                        parameterValue = values[index1];
                        break;

Для линейной интерполяции используем функции glm::lerp (линейная интерполяция) для положения и масштабирования и glm::slerp (сферическая линейная интерполяция) для поворота:

                    case LINEAR:
                        if (path == ROTATION)
                            parameterValue.quat = glm::slerp(values[index1].quat, values[index2].quat, t);
                        else
                            parameterValue.vec3 = glm::lerp(values[index1].vec3, values[index2].vec3, t);
                        break;

Для кубической-сплайн интерполяции необходимо учитывать кратчайший путь для кватернионов поворота:

                    case CUBICSPLINE:
                        float t2 = t*t;
                        float t3 = t2*t;
                        if (path == ROTATION)
                        {
                            // Обеспечение кратчайшего пути для интерполяции
                            glm::quat q2Adjusted =   values[index2].    quat;
                            glm::quat t2Adjusted = tangents[index2].in. quat;
                            if (glm::dot(values[index1].quat, values[index2].quat) < 0) 
                            {
                                q2Adjusted = -q2Adjusted;
                                t2Adjusted = -t2Adjusted;
                            }
                            parameterValue.quat = (2*t3 - 3*t2 + 1) *   values[index1].    quat 
                                                + (t3 - 2*t2 + t) *   tangents[index1].out.quat 
                                                + (-2*t3 + 3*t2) *    q2Adjusted
                                                + (t3 - t2) *         t2Adjusted;
                            parameterValue.quat = glm::normalize(parameterValue.quat);
                        }   
                        else
                            parameterValue.vec3 = (2*t3 - 3*t2 + 1) *   values[index1].    vec3 
                                                + (t3 - 2*t2 + t) *   tangents[index1].out.vec3 
                                                + (-2*t3 + 3*t2) *      values[index2].    vec3 
                                                + (t3 - t2) *         tangents[index2].in. vec3;
                        break;
                }
            }
        }

Осталось лишь применить полученные результаты интерполяции для анимируемого параметра:

        if (path == POSITION)
            target->e_position() = parameterValue.vec3;
        if (path == ROTATION)
            target->e_rotation() = parameterValue.quat;
        if (path == SCALE)
            target->e_scale() = parameterValue.vec3;
        
    }
}

Важное замечание: для функций glm::lerp и glm::slerp необходимо подключить заголовочный файл GLM/gtx/compatibility.hpp

В файл include/Animation.h добавим класс анимации:

// Класс анимации
class Animation
{
    public:
        void begin(); // Задает состояние анимации как начало через запоминание времени
        void end(); // Заканчивает выполнение анимации
        void process(); // Вычисляет анимацию для каждого канала на основании текущего времени
        bool isEnabled(); // Возвращает состояние анимации (вкл/выкл)
        std::vector<Channel> channels; // Каналы анимации
        ANIM_ENDINGS endings = CYCLED;
    private:
        std::chrono::steady_clock::time_point begin_time; // Время начала анимации
        bool enabled = false;
};

Данный класс имеет методы Animation::begin — для сброса времени начала анимации, Animation::end — для выключения анимации, Animation::isEnabled — для доступа к состоянию анимации (вкл/выкл) и Animation::process — для применения анимации по каналам для выбранных моделей. Реализация данных методов:

// Задает состояние анимации как начало
void Animation::begin()
{
    begin_time = std::chrono::steady_clock::now();
    enabled = true;
}

// Заканчивает выполнение анимации
void Animation::end()
{
    enabled = false;
}

// Возвращает состояние анимации (вкл/выкл)
bool Animation::isEnabled() 
{
    return enabled;
}

// Выполняет анимацию для всех каналов
void Animation::process()
{
    float dtime = std::chrono::duration<float, std::milli>(std::chrono::steady_clock::now() - begin_time).count() / 1000.0f;
    for (Channel & channel : channels)
    {
        // channel.interpolation = STEP;
        channel.process(dtime, endings);
    }
}

Текущая версия доступна на теге v0.1 в репозитории 20.

Функция-загрузчик

В файле include/Scene.h добавим список анимаций и словарь названий анимаций в соответствие индексам анимаций к сцене:

// Класс сцены
class Scene
{
    public:
...
        std::vector<Animation> animations; // Список анимаций
        std::map<std::string, size_t> animation_names; // Имя анимации - индекс
...
};

В файле src/Scene.cpp добавим вспомогательную функцию для переконвертации данных во float:

float getFloatChannelOutput(int type, const void* array, int index)
{
    float result;
    
    switch (type)
    {
        case TINYGLTF_COMPONENT_TYPE_BYTE:
        {
            const char*            bvalues = reinterpret_cast<const char*>          (array);
            result =  bvalues[index] / 127.0;
            if (result < -1.0)
                result = -1.0;
        
            break;
        }
        case TINYGLTF_COMPONENT_TYPE_UNSIGNED_BYTE:
        {
            const unsigned char*  ubvalues = reinterpret_cast<const unsigned char*> (array);
            result = ubvalues[index] / 255.0;
        
            break;
        }
        case TINYGLTF_COMPONENT_TYPE_SHORT:
        {
            const short*           svalues = reinterpret_cast<const short*>         (array);
            result =  svalues[index] / 32767.0;
            if (result < -1.0)
                result = -1.0;

            break;
        }
        case TINYGLTF_COMPONENT_TYPE_UNSIGNED_SHORT:
        {
            const unsigned short* usvalues = reinterpret_cast<const unsigned short*>(array);
            result = usvalues[index] / 65535.0;
        
            break;
        }
        default:
        {
            const float*           fvalues = reinterpret_cast<const float*>         (array);
            result =  fvalues[index];
        }
    
    }

    return result;
}

Дополним функцию-загрузчик loadGLTFtoScene частью по загрузке анимаций после загрузки сцен:

Scene loadGLTFtoScene(std::string filename)
{
...
    // Если все успешно считано - продолжаем загрузку
    if (success)
    {
...
        // Цикл по сценам
        for (auto &scene : in_model.scenes)
        {
...
        }

        // Цикл по анимациям
        for (auto &in_animation : in_model.animations)
        {
            Animation animation;

Для каждой анимации обработаем каналы:

            for (auto &in_channel : in_animation.channels)
            {
                Channel channel;

Определим целевой узел и параметр анимации:

                channel.target = pNodes[in_channel.target_node]; // Анимируемый узел
                // Анимируемый параметр
                if (in_channel.target_path == "translation")
                    channel.path = POSITION;
                else
                if (in_channel.target_path == "rotation")
                    channel.path = ROTATION;
                else
                if (in_channel.target_path == "scale")
                    channel.path = SCALE;
                else
                    throw std::runtime_error("Неподдерживаемый параметр анимации");

                
                // Получение сэмплера для канала
                const auto& sampler = in_animation.samplers[in_channel.sampler];

                // Тип интерполяции
                if (sampler.interpolation == "LINEAR")
                    channel.interpolation = LINEAR;
                else
                if (sampler.interpolation == "STEP")
                    channel.interpolation = STEP;
                else
                if (sampler.interpolation == "CUBICSPLINE")
                    channel.interpolation = CUBICSPLINE;
                else
                    throw std::runtime_error("Неподдерживаемый тип интерполяции");

Скопируем временные метки:

                // Получение временных меток ключевых кадров (Input Accessor)
                const auto& inputAccessor = in_model.accessors[sampler.input];
                const auto& inputBufferView = in_model.bufferViews[inputAccessor.bufferView];
                const auto& inputBuffer = in_model.buffers[inputBufferView.buffer];
                const float* keyframeTimes = reinterpret_cast<const float*>(&inputBuffer.data[inputBufferView.byteOffset + inputAccessor.byteOffset]);
                // Скопируем через метод insert
                channel.timestamps.insert(channel.timestamps.end(), keyframeTimes, keyframeTimes + inputAccessor.count);

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

                // Получение данных ключевых кадров (Output Accessor)
                const auto& outputAccessor = in_model.accessors[sampler.output];
                const auto& outputBufferView = in_model.bufferViews[outputAccessor.bufferView];
                const auto& outputBuffer = in_model.buffers[outputBufferView.buffer];

                // Зарезервируем место
                channel.values.resize(inputAccessor.count);
                if (channel.interpolation == CUBICSPLINE)
                    channel.tangents.resize(inputAccessor.count);

В зависимости от анимируемого параметра и типа данных запустим циклы по ключевым кадрам и компонентам:

                // Проверим формат и запишем данные с учетом преобразования
                if ((   (channel.path == POSITION || channel.path == SCALE)
                     && outputAccessor.type == TINYGLTF_TYPE_VEC3) // == 3
                ||  (    channel.path == ROTATION
                     && outputAccessor.type == TINYGLTF_TYPE_VEC4) // == 4
                   )
                {
                    // Цикл по ключевым кадрам
                    for (int keyframe = 0; keyframe < inputAccessor.count; keyframe++)
                        // Цикл по компонентам
                        for (int component = 0; component < outputAccessor.type; component++)
                        {

В случае, если тип интерполяции — cubic-spline, то необходимо компоновать анимацию в соответствии с форматом: in-tangent, value, out-tangent — повекторно, что обеспечивается сдвигами в индексах: keyframe — индекс кадра (определяет сдвиг по пачкам), outputAccessor.type — соответствует длинне вектора (3 или 4), умножение на 3 — по три вектора на пачку (in-tangent, value, out-tangent), а так же дополнительный сдвиг на outputAccessor.type — сдвиг между векторами в пачке.

                            // Для CUBICSPLINE интерполяции требуется дополнительно списать касательные
                            if (channel.interpolation == CUBICSPLINE)
                            {
                                if (channel.path == ROTATION)
                                {
                                    channel.tangents[keyframe].in. quat[component] = getFloatChannelOutput(outputAccessor.componentType, &outputBuffer.data[outputBufferView.byteOffset + outputAccessor.byteOffset], keyframe*outputAccessor.type*3                         + component);
                                    channel.values  [keyframe].    quat[component] = getFloatChannelOutput(outputAccessor.componentType, &outputBuffer.data[outputBufferView.byteOffset + outputAccessor.byteOffset], keyframe*outputAccessor.type*3 + outputAccessor.type   + component);
                                    channel.tangents[keyframe].out.quat[component] = getFloatChannelOutput(outputAccessor.componentType, &outputBuffer.data[outputBufferView.byteOffset + outputAccessor.byteOffset], keyframe*outputAccessor.type*3 + outputAccessor.type*2 + component);
                                }
                                else
                                {
                                    channel.tangents[keyframe].in. vec3[component] = getFloatChannelOutput(outputAccessor.componentType, &outputBuffer.data[outputBufferView.byteOffset + outputAccessor.byteOffset], keyframe*outputAccessor.type*3                         + component);
                                    channel.values  [keyframe].    vec3[component] = getFloatChannelOutput(outputAccessor.componentType, &outputBuffer.data[outputBufferView.byteOffset + outputAccessor.byteOffset], keyframe*outputAccessor.type*3 + outputAccessor.type   + component);
                                    channel.tangents[keyframe].out.vec3[component] = getFloatChannelOutput(outputAccessor.componentType, &outputBuffer.data[outputBufferView.byteOffset + outputAccessor.byteOffset], keyframe*outputAccessor.type*3 + outputAccessor.type*2 + component);
                                }
                            }

Для других типов интерполяции загрузка выглядит намного проще:

                            else
                                if (channel.path == ROTATION)
                                    channel.values  [keyframe].    quat[component] = getFloatChannelOutput(outputAccessor.componentType, &outputBuffer.data[outputBufferView.byteOffset + outputAccessor.byteOffset], keyframe*outputAccessor.type + component);
                                else
                                    channel.values  [keyframe].    vec3[component] = getFloatChannelOutput(outputAccessor.componentType, &outputBuffer.data[outputBufferView.byteOffset + outputAccessor.byteOffset], keyframe*outputAccessor.type + component);
                        }

Если условный оператор при проверке формата не выполнился — выдадим исключение о «неподдерживаемых данных анимации»:

                }
                else
                    throw std::runtime_error("Неподдерживаемые данные анимации");

В конце цикла по каналам добавим канал к анимации, а в конце цикла по анимациям добавим анимацию к списку анимаций, а так же в словарь имя анимации/индекс в массиве:

                animation.channels.push_back(channel);
            }

            // Имя анимации
            // Если имени нет - сгенерируем
            if (in_animation.name.empty())
            {
                std::string name = filename + result.animations.size();
                result.animation_names[name] = result.animations.size();
            }
            else
                result.animation_names[in_animation.name] = result.animations.size();

            result.animations.push_back(animation);
        }

В методе Scene::render пройдем по анимациям и вызовем метод Animation::process для тех анимаций, которые включены. Так как за цикл рисования кадра рендер сцены может вызываться несколько раз, то следует добавить флаг необходимости пересчета:

// Рендер сцены
void Scene::render(ShaderProgram &shaderProgram, UBO &material_buffer, bool recalc_animations) 
{
    // Если требуется пересчитаем анимации
    if (recalc_animations)
        for (auto & animation : animations)
            if (animation.isEnabled())
                animation.process();

    // Рендер моделей
    for (auto & model : models)
        model.render(shaderProgram, material_buffer);
}

В файле include/Scene.h добавим вспомогательный шаблонный метод, аналогичный Scene::move_parent, а так же добавим флаг к объявлению метода Scene::render (по умолчанию выключен):

// Класс сцены
class Scene
{
...
        void render(ShaderProgram &shaderProgram, UBO &material_buffer, bool recalc_animations = false); // Рендер сцены
...
    protected:
...
        template <class T>
        void move_parent(Node& for_node, const std::list<T>& from_nodes, std::list<T>& this_nodes); // Сдвигает родителя узла между двумя списками при условии его принадлежности к оригинальному
        template <class T>
        void move_animation_target(Node*& target, const std::list<T>& from_nodes, std::list<T>& this_nodes); // Перестройка узлов анимации
};

Осталось дополнить конструкторы и оператор присваивания с переопределением указателей на узлы в методе Scene::rebuld_tree (файл src/Scene.cpp):

// Конструктор копирования
Scene::Scene(const Scene &copy): root(copy.root), 
nodes(copy.nodes), models(copy.models), cameras(copy.cameras), 
animations(copy.animations), animation_names(copy.animation_names)
{
    rebuld_tree(copy);
} 

// Оператор присваивания
Scene& Scene::operator=(const Scene& other) 
{
    root = other.root;
    nodes = other.nodes;
    models = other.models;
    cameras = other.cameras;
    animations = other.animations;
    animation_names = other.animation_names;

    rebuld_tree(other);

    return *this;
} 

...

// Перестройка узлов анимации
template <class T>
void Scene::move_animation_target(Node*& target, const std::list<T>& from_nodes, std::list<T>& this_nodes)
{
    // Цикл по элементам списков для перемещения родителя
    // Списки в процессе копирования идеинтичные, вторая проверка не требуется
    for (auto it_from = from_nodes.begin(), it_this = this_nodes.begin(); it_from != from_nodes.end(); ++it_from, ++it_this)
        // Если адрес объекта, на который указывает итератор, совпадает с родителем - меняем родителя по второму итератору (it_this)
        if (&(*it_from) == target)
            target = &(*it_this);
}

// Перестройка дерева после копирования или присваивания
void Scene::rebuld_tree(const Scene& from)
{    
    // Восстановим родителей в пустых узлах для копии
    rebuild_Nodes_list(nodes, from);
    rebuild_Nodes_list(models, from);
    rebuild_Nodes_list(cameras, from);

    // Восстановим указатели на узлы для каналов анимаций
    for (auto & animation : animations)
        for (auto & channel : animation.channels)
        {
            // Если целевой узел - оригинальный корневой узел, то меняем на собственный корневой узел
            if (channel.target == &from.root) 
            {
                channel.target = &root;
                continue;
            }

            // Если можно привести к модели, то ищем родителя среди моделей
            if (dynamic_cast<Model*>(channel.target))
                move_animation_target(channel.target, from.models, this->models);
            else
            // Иначе проверяем на принадлежность к камерам
            if (dynamic_cast<Camera*>(channel.target))
                move_animation_target(channel.target, from.cameras, this->cameras);
            // Иначе это пустой узел
            else
                move_animation_target(channel.target, from.nodes, this->nodes);
            
            // Не нашли узел - значит он не часть этой сцены 
            // и изменений по каналу не требуется
        }
}

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

Текущая версия доступна на теге v0.2 в репозитории 20.

Примеры

В качестве примера можно взять модель из публичного репозитория Khronos Group / glTF-Sample-Models, либо использовать модель кубика из репозитория resources:

В файле src/main.cpp укажем загрузку нужного кубика (например с кубической-сплайн интерполяцией) и включим анимацию:

    // Загрузка сцены из obj файла
    Scene scene = loadGLTFtoScene("../resources/models/rotating-cube_cubic-spline.gltf");  
    scene.root.e_scale() = glm::vec3(0.5);
    scene.root.e_position().y = -1;
    scene.root.e_position().z = 3;
    scene.set_group_id((GLuint64) &scene.root);
    // Включим первую анимацию, если есть
    if (scene.animations.size())
        scene.animations[0].begin();

А так же в цикле рисования укажем пересчет анимаций при первой отрисовке в G-буфер:

    // Пока не произойдет событие запроса закрытия окна
    while(!glfwWindowShouldClose(window))
    {
...
        // Тут производится рендер
        scene.render(gShader, material_data, true);
        rectangle.render(gShader, material_data);

На рисунках 6, 7 и 8 представлены результаты работы программы с различными видами интерполяции.

Рисунок 6 — кубик с константной интерполяцией поворота
Рисунок 7 — кубик с линейной интерполяцией
Рисунок 8 — кубик с кубической-сплайн интерполяцией

Текущая версия доступна на теге v0.3 в репозитории 20.

Заключение

В рамках данной заметки рассмотрены базовые анимации узлов и их хранение в формате моделей glTF 2.0, а так же дополнена функция для загрузки анимаций сцен.

Проект доступен в публичном репозитории: 20.
Библиотеки: dependencies
Ресурсы: resources

Один ответ к “OpenGL 20: загрузка .glTF ч.2 — базовые анимации”

Ох как же я тупил (код очень сложен для понимания новичку) в итоге сегодня я понял как оно работает (понял как много штучек работает с 1 урока всё казалось сложным и дальше шли непонятные мне вещи но благо чат гпт мне хоть как-то смог объяснить ваши слова спасибо вам)

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Этот сайт использует Akismet для борьбы со спамом. Узнайте, как обрабатываются ваши данные комментариев.

Управление cookie