Введение
В современной цифровой графике анимация играет ключевую роль, оживляя персонажей и сцены, создавая динамичные и захватывающие визуальные эффекты. В данной заметке автор сосредоточится на ключевых аспектах анимаций в формате 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 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 используется сферическая линейная интерполяция для кватерниона, описывающего поворот, которая рассматривается после интерактивного примера.
Далее представлен интерактивный пример изменения величины:
Точка 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.
Далее представлен интерактивный пример изменения величины:
Точка 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 используется один и тот же файл с анимированным кубиком, но расхождения в положении кубика обусловлены расхождением начала записи с экрана.
Помимо этого кватернион требуется нормировать перед применением итогового вращения к объекту на сцене.
Анимации в формате 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.
Анимируемый параметр | Размер вектора | Тип данных компоненты | Описание |
---|---|---|---|
translation | glm::vec3 | float | Вектор смещения (позиции) в формате XYZ |
rotation | glm::quat (glm::vec4) | float, signed byte normalized, unsigned byte normalized, signed short normalized, unsigned short normalized | Кватернион поворота в формате XYZW |
scale | glm::vec3 | float | Вектор масштабирования в формате XYZ |
weights | glm::vec1 (scalar) | float, signed byte normalized, unsigned byte normalized, signed short normalized, unsigned short normalized | Веса для анимации преобразования (морфинг) |
Важное замечание: в Blender по умолчанию ось Z направлена вверх, что ведет к перемене мест координат осей Y и Z, а так же ось, направленная на камеру инвертирована.
Спецификация по формату glTF гласит: «Реализации ДОЛЖНЫ использовать следующие уравнения для декодирования реального значения с плавающей точкой из нормализованного целого числа и наоборот». Уравнения для конвертации представлены в таблице 2.
Тип компоненты | int ➙ float | float ➙ int |
---|---|---|
signed byte | f = max(c / 127.0, -1.0) | c = round(f * 127.0) |
unsigned byte | f = c / 255.0 | c = round(f * 255.0) |
signed short | f = max(c / 32767.0, -1.0) | c = round(f * 32767.0) |
unsigned short | f = c / 65535.0 | c = round(f * 65535.0) |
Для кубической сплайн-интерполяции в массиве значений дополнительно хранятся входящие и исходящие касательные (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 ©): 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 представлены результаты работы программы с различными видами интерполяции.
Текущая версия доступна на теге v0.3 в репозитории 20.
Заключение
В рамках данной заметки рассмотрены базовые анимации узлов и их хранение в формате моделей glTF 2.0, а так же дополнена функция для загрузки анимаций сцен.
Проект доступен в публичном репозитории: 20.
Библиотеки: dependencies
Ресурсы: resources
Один ответ к “OpenGL 20: загрузка .glTF ч.2 — базовые анимации”
Ох как же я тупил (код очень сложен для понимания новичку) в итоге сегодня я понял как оно работает (понял как много штучек работает с 1 урока всё казалось сложным и дальше шли непонятные мне вещи но благо чат гпт мне хоть как-то смог объяснить ваши слова спасибо вам)