Что такое GLTF и GLB?
GLTF (GL Transmission Format) — это формат файла для хранения 3Д сцен и моделей, который является крайне простым в понимании (структура записана в стандарте JSON), расширяемым и легко взаимодействующим с современными веб-технологиями. Данный формат хорошо сжимает трёхмерные сцены и минимизирует обработку во время выполнения приложений, использующих WebGL и другие API. GLTF сейчас активно продвигается Khronos Group как JPEG от мира 3D. На сегодняшний день используется GLTF версии 2.0. Существует и бинарная версия данного формата, которая называется GLB, единственное различие которого в том, что все хранится в одном файле с расширением GLB.
Эта статья — 1 часть из 2х. В ней мы с вами рассмотрим такие артефакты формата и их атрибуты, как Scene, Node, Buffer, BufferView, Accessor и Mesh. А во второй статье мы рассмотрим оставшиеся: Material, Texture, Animations, Skin и Camera. Больше общей информации о формате можно найти здесь.
Если в процессе просмотра статьи захочется лично поработать с данным форматом, то можете скачать модели GLTF 2.0 с официального репозитория Khronos на GitHub
Проблематика и её решение
Изначально GLTF формат был задуман Khronos Group как решение для передачи 3D контента по интернету и был призван минимизировать количество импортеров и конвертеров, разные виды которых создаются при работе с графическими API.
На текущий момент GLTF и его бинарный брат GLB используются как унифицированные форматы и в CAD программах (Autodesk Maya, Blender и т. д.), в игровых движках (Unreal Engine, Unity и прочих), AR/VR приложениях, соц. сетях и т.д.
Представители Khronos Group утрвеждают следующее:
- GLTF универсален — может использоваться одинаково хорошо как для простой геометрии, так и для сложных сцен с анимацией, различными материалами и т. д.
- Он достаточно компактен. Да, это можно оспорить, ведь всё зависит от от алгоритмов конвертации и я лично знаю случаи, когда GLTF был больше размером, чем оригинальный, к примеру, FBX файл, но в большинстве случаев это так.
- Простота анализа данных – это корневой плюс данного формата. GLTF иерархия использует JSON, а геометрия хранится в бинарном виде, никакого декодинга не нужно!
Система координат и единицы измерения
GLTF использует правостороннюю систему координат, то есть перекрестное произведение +X и +Y дает +Z, где +Y — верхняя ось. Передняя часть 3D ассета GLTF обращена к оси +Z. Единицами измерения для всех линейных расстояний являются метры, углы же измеряются в радианах а положительное вращение объектов — против часовой стрелки. Node трансформации и channel paths анимаций являются трехмерными векторами или кватернионами со следующими типами данных и семантикой:
translation: трехмерный вектор, содержащий перевод по осям x, y и z
rotation: кватернион (x, y, z, w), где w скаляр
scale: трехмерный вектор, содержащий коэффициенты масштабирования по осям x, y и z
GLTF — взгляд изнутри
Как было сказано выше GLTF, как правило, состоит из 2х файлов: 1й с форматом .gltf, который хранит в себе структуру 3D сцены в виде JSON и 2й файл с форматом .bin, который хранит уже непосредственно все данные этой сцены.
Структура формата строго иерархическая и имеет следующий вид:
Рассказывая далее о структуре я буду использовать примеры простейшего GLTF файла, который хранит в себе 1 односторонний треугольник с материалом по умолчанию. Если захотите, то вы можете его скопировать и вставить в любой GLTF просмотрщик, чтобы "пощупать" содержимое файла лично. В своей практике я использовал разные, но остановился на этом, который использует Three.js под капотом. Также хорошей опцией будет использование Visual Studio Code с GLTF плагином. Так у вас появится выбор сразу из 3х движков: Babylon.js, Cesium, Three.js
Scene и Node элементы
Первым-наперво идет основная нода под названием Scene. Это корневая точка в файле, с которой все и начинается. Данная нода содержит массив сцен, которые хранит GLTF и выбор той, которая будет грузится по умолчанию после открытия файла. Контент же 3D сцены начинается со следующего объекта, который называется “Node”. Массив сцен и нод был упомянут не зря, т.к. возможность хранить несколько сцен в одном файле реализована, но на практике стараются хранить одну сцену в одном файле.
{
"scenes" : [
{
"nodes" : [ 0 ]
}
],
"nodes" : [
{
"mesh" : 0
}
],
"scene": 0
Каждая нода является “входной точкой” для описания отдельных объектов. Если объект сложный и состоит из нескольких мешей, то такой объект будет описан «родительской» и «дочерними» нодами. Например, автомобиль, который состоит из корпуса и колес, может быть описан следующим образом: основная нода описывает машину и, в частности, ее корпус. В этой ноде содержится список “дочерних нод”, которые, в свою очередь, описывают уже оставшиеся составные части, такие как, к примеру, колеса. Обработка всех элементов будет осуществляться рекурсивно. Ноды могут иметь TRS (translation, rotation, scale a.k.a. смещени е, поворот и масштабирование) анимации. Кроме того, что такие трансформации влияют непосредственно на сам меш, они точно также воздействуют и на дочерние ноды. В довесок ко всему вышесказанному думаю стоит упомянуть, что внутренние "камеры", если таковые имеются, которые отвечают за отображение для пользователя объекта в кадре, также прикреплены к объекта Node. Объекты ссылаются друг на друга используя соответствующий атрибуты: scene имеет атрибут node, node объект имеет атрибут mesh. Для более простого понимания всё вышесказанное проилюстрировано на следующем рисунке.
Buffer, BufferView и Accessor
Под объектом Buffer подразумевается хранилище бинарных, не обработанных, данных без структуры, без наследования, без значения. В буфере хранится информация о геометрии, анимациях и скиннинге. Главное преимущество бинарных данных в том, что они крайне эффективно обрабатываются GPU, т.к. не требуют дополнительного парсинга, кроме, возможно, декомпрессии. Данные в буфере могут быть найдены по атрибуту URI, который явно дает понять где находятся данные и здесь всего 2 варианта: либо данные хранятся во внешнем файле с форматом .bin, либо они встроены внутрь самого JSON. В первом случае URI содержит ссылку на внешний файл, в этом случае папка, в которой находится GLTF файл, считается корневой. Во втором случае файл будет иметь формат .glb, отсылающий нас к более компактному, с точки зрения количества файлов, брату-близнецу GLTF, формату GLB. Данные в бинарном файле хранятся как есть, побайтово.
JSON в нашем примере с треугольником будет выглядеть следующим образом:
Пример буфера, закодированного в base64:
"buffers" : [
{
"uri" : "data:application/octet-stream;base64,AAABAAIAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAACAPwAAAAA=",
"byteLength" : 44
}
],
Если же у вас будет внеший файл, то JSON преобразует свой вид в следующий:
"buffers" : [
{
"uri" : "duck.bin",
"byteLength" : 102040
}
],
Блок Buffers также имеет дополнительный атрибут byteLength, который хранит в себе значение размера буфера.
Первым шагом в структуризации данных из буфера служит объект BufferView. BufferView можно назвать "срезом" информации из Buffer, который характеризуется определенным сдвигом байт от начала буфера. Данный "срез" описывается при помощи 2х атрибутов: отсчет “сдвига” от начала буфера для считывания и длинной самого среза. Простой пример нескольких объектов BufferView для наглядности их использования на основе нашего примера:
"bufferViews" : [
{
"buffer" : 0,
"byteOffset" : 0,
"byteLength" : 6,
"target" : 34963
},
{
"buffer" : 0,
"byteOffset" : 8,
"byteLength" : 36,
"target" : 34962
}
],
Как вы видите, в данном примере содержится 4 основных атрибута:
- Buffer указывает на индекс буфера (порядковый номер в массиве буферов, начинается с 0).
- byteOffset — определяет “сдвиг” начала отсчета в байтах для данного “среза”
- byteLength — определяет длину “среза”
- target — определяет тип данных, содержащихся в bufferView
Первый BufferView содержит первые 6 байт буфера и не имеет сдвига. Со вторым "срезом" все немного сложнее: как видите, его сдвиг находится на 8м байте, вместо ожидаемого 6го. Данные 2 байта являются пустыми и были добавлены в процессе формирования буфера благодаря процессу под названием "padding". Оно нужно, чтобы значение подогнать значение байт границы в 4 байта. Такой трюк нужен для более быстрого и легкого считывания данных из буфера.
Стоит сказать еще пару слов об атрибуте target. Он используется для классификации типа информации на которую ссылается bufferView. Здесь всего 2 варианта: либо это будет значение 34962, которое используется для ссылки на атрибуты вертексов (vertex attributes — 34962 — ARRAY_BUFFER) или же 34963, которое используется для индексов вертексов (vertex indices — 34963 — ELEMENT_ARRAY_BUFFER). Последним штрихом при для понимания и структуризации всей информации в Buffer является объект Accessor.
Accessor — это объект, который обращается к BufferView и содержит атрибуты, которые определяют тип и расположение данных из BufferView. Тип данных аксессора кодируется в type и componentType. Значением атрибута type является строка и имеет следующие значения: SCALAR для скалярных значений, VEC3 для 3х мерных векторов и MAT4 для матрицы размерностью 4х4 или же кватерниона, который используется для описания rotation (поворота).
В свою очередь componentType указывает тип компонентов этих данных. Это GL константа, которая может иметь такие значение, как, к примеру, 5126 (FLOAT) или 5123 (UNSIGNED_SHORT), для указания того, что элементы имеют плавающую запятую и т.п.
Различные комбинации этих свойств могут использоваться для описания произвольных типов данных. Пример основанный на нашем треугольнике.
"accessors" : [
{
"bufferView" : 0,
"byteOffset" : 0,
"componentType" : 5123,
"count" : 3,
"type" : "SCALAR",
"max" : [ 2 ],
"min" : [ 0 ]
},
{
"bufferView" : 1,
"byteOffset" : 0,
"componentType" : 5126,
"count" : 3,
"type" : "VEC3",
"max" : [ 1.0, 1.0, 0.0 ],
"min" : [ 0.0, 0.0, 0.0 ]
}
],
Разберём атрибуты, представленные в JSON:
- bufferView — указывает порядковый номер BufferView из массива BufferView, который использует Accessor. BufferView же, в свою очередь, хранит информацию об индексах.
- byteOffset — сдвиг байт для начала считывания данных текущим Accessor. На один BufferView может ссылаться несколько объектов типа Accessor.
- componentType — константа, указывающая на тип элементов. Может иметь значения 5123, которой соответствует тип данных UNSIGNED_SHORT или же 5126 для FLOAT.
- count — отображает как много элементов хранится в buffer.
- type — определяет тип данных: скаляр, вектор, матрица.
- max и min — атрибуты, которые определяют минимальное и максимальное значение положение данных элементов в пространстве.
Mesh
Объект Meshes содержит информацию о мешах, расположенных в сцене. Одна нода (node объект) может хранить только 1 меш. Каждый объект типа mesh содержит массив типа mesh.primitive, в свою очередь примитивы — это примитивные объекты (к примеру треугольники) из которых состоит непосредственно меш. Данный объект содержит много дополнительных атрибутов, но все это служит одной цели — правильному хранению информации об отображении объекта. Основные атрибуты меша:
- POSITION — позиция вертексов по осям XYZ
- NORMAL — нормализованные XYZ нормали вертексов
- TANGENT — XYZW тангентсы вертексов. W указывает куда направлен тангент и имеет значечние либо +1, либо -1.
- TEXCOORD_0 — текстурные координаты UV. Может хранится несколько наборов.
- COLOR_0 — RGB или RGBA цвета вертексов.
- JOINTS_0 — данный атрибут содержит индексы суставов/джоинтов (Joints) из соответствующего массива joints, которые должны влиять на вертекс (вершину).
- WEIGHTS_0 — данные этого атрибута определяют веса, указывающие насколько сильно сустав/joint влияет на вершину.
- weights — атрибут, отвечающий за веса морфинга.
- material — содержит индекс, который является номером материала в массиве Materials
Данный объект будет иметь следующий вид для нашего случая:
"meshes" : [
{
"primitives" : [ {
"attributes" : {
"POSITION" : 1
},
"indices" : 0
} ]
}
],
К сожалению из-за ограничения весь материал не вместился с одну статью, поэтому оставшуюся часть можно найти во второй статье, в которой мы рассмотрим оставшиеся артефакты: Material, Texture, Animations, Skin и Camera, а также соберём минимальный рабочий GLTF файл.
whitemonkey
Если правильно понял, то несколько нод могут ссылаться на один меш, но например с разными вариантами ориентации. По сути это реализация инстансов. Это очень нужно, и до GLTF инстансы по сути были реализованы (через правильное место и вариативно) только в FBX.
Или это не так и при экспорте например из Блендера или Макса будет сгенерирован меш на каждую ноду?
BloodyPoSTaL Автор
Всё верно, в случае инстансинга нода ссылается на существующий меш, а трансформация ноды уже перемещает инстанс по сцене.
Chaos_Optima
А ещё в алембике и USD, а ещё их можно было самостоятельно реализовать в колладе