Skip to content

Commit e8089ca

Browse files
committed
Add: Decompose transformation matrices while parsing
This adds a option enum that makes fastgltf decompose node matrices into the TRS components for ease of use further on.
1 parent f340ef0 commit e8089ca

File tree

9 files changed

+250
-60
lines changed

9 files changed

+250
-60
lines changed

CMakeLists.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,5 +72,5 @@ else()
7272
endif()
7373

7474
add_subdirectory(src)
75-
add_subdirectory(tests)
7675
add_subdirectory(examples)
76+
add_subdirectory(tests)

src/fastgltf.cpp

Lines changed: 55 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#include <array>
2+
#include <cmath>
23
#include <fstream>
34
#include <functional>
45
#include <utility>
@@ -495,6 +496,12 @@ fg::Error fg::glTF::validate() {
495496
return Error::InvalidGltf;
496497
if (node.meshIndex.has_value() && parsedAsset->meshes.size() <= node.meshIndex.value())
497498
return Error::InvalidGltf;
499+
500+
if (!node.hasMatrix) {
501+
for (auto& x : node.transform.trs.rotation)
502+
if (x > 1.0 || x < -1.0)
503+
return Error::InvalidGltf;
504+
}
498505
}
499506

500507
for (const auto& scene : parsedAsset->scenes) {
@@ -1381,73 +1388,71 @@ void fg::glTF::parseNodes(simdjson::dom::array& nodes) {
13811388
}
13821389
}
13831390

1384-
dom::array matrix;
1385-
if (nodeObject["matrix"].get_array().get(matrix) == SUCCESS) {
1391+
dom::array array;
1392+
auto error = nodeObject["matrix"].get_array().get(array);
1393+
if (error == SUCCESS) {
13861394
node.hasMatrix = true;
13871395
auto i = 0U;
1388-
for (auto num : matrix) {
1396+
for (auto num : array) {
13891397
double val;
13901398
if (num.get_double().get(val) != SUCCESS) {
13911399
node.hasMatrix = false;
13921400
break;
13931401
}
1394-
node.matrix[i] = static_cast<float>(val);
1402+
node.transform.matrix[i] = static_cast<float>(val);
13951403
++i;
13961404
}
1397-
} else {
1398-
// clang-format off
1399-
node.matrix = {
1400-
1.0f, 0.0f, 0.0f, 0.0f,
1401-
0.0f, 1.0f, 0.0f, 0.0f,
1402-
0.0f, 0.0f, 1.0f, 0.0f,
1403-
0.0f, 0.0f, 0.0f, 1.0f,
1404-
};
1405-
// clang-format on
1406-
}
1407-
1408-
dom::array scale;
1409-
if (nodeObject["scale"].get_array().get(scale) == SUCCESS) {
1410-
auto i = 0U;
1411-
for (auto num : scale) {
1412-
double val;
1413-
if (num.get_double().get(val) != SUCCESS) {
1414-
SET_ERROR_RETURN(Error::InvalidGltf)
1405+
1406+
if (hasBit(options, Options::DecomposeNodeMatrices)) {
1407+
node.hasMatrix = false;
1408+
// Create a copy of the matrix, as we store the transform in a union.
1409+
auto matrix = node.transform.matrix;
1410+
decomposeTransformMatrix(matrix, node.transform.trs.scale, node.transform.trs.rotation, node.transform.trs.translation);
1411+
}
1412+
} else if (error == NO_SUCH_FIELD) {
1413+
node.hasMatrix = false;
1414+
// There's no matrix, let's see if there's scale, rotation, or rotation fields.
1415+
if (nodeObject["scale"].get_array().get(array) == SUCCESS) {
1416+
auto i = 0U;
1417+
for (auto num : array) {
1418+
double val;
1419+
if (num.get_double().get(val) != SUCCESS) {
1420+
SET_ERROR_RETURN(Error::InvalidGltf)
1421+
}
1422+
node.transform.trs.scale[i] = static_cast<float>(val);
1423+
++i;
14151424
}
1416-
node.scale[i] = static_cast<float>(val);
1417-
++i;
1425+
} else {
1426+
node.transform.trs.scale = {1.0f, 1.0f, 1.0f};
14181427
}
1419-
} else {
1420-
node.scale = {1.0f, 1.0f, 1.0f};
1421-
}
14221428

1423-
dom::array translation;
1424-
if (nodeObject["translation"].get_array().get(translation) == SUCCESS) {
1425-
auto i = 0U;
1426-
for (auto num : translation) {
1427-
double val;
1428-
if (num.get_double().get(val) != SUCCESS) {
1429-
SET_ERROR_RETURN(Error::InvalidGltf)
1429+
if (nodeObject["translation"].get_array().get(array) == SUCCESS) {
1430+
auto i = 0U;
1431+
for (auto num : array) {
1432+
double val;
1433+
if (num.get_double().get(val) != SUCCESS) {
1434+
SET_ERROR_RETURN(Error::InvalidGltf)
1435+
}
1436+
node.transform.trs.translation[i] = static_cast<float>(val);
1437+
++i;
14301438
}
1431-
node.translation[i] = static_cast<float>(val);
1432-
++i;
1439+
} else {
1440+
node.transform.trs.translation = {0.0f, 0.0f, 0.0f};
14331441
}
1434-
} else {
1435-
node.translation = {0.0f, 0.0f, 0.0f};
1436-
}
14371442

1438-
dom::array rotation;
1439-
if (nodeObject["rotation"].get_array().get(rotation) == SUCCESS) {
1440-
auto i = 0U;
1441-
for (auto num : rotation) {
1442-
double val;
1443-
if (num.get_double().get(val) != SUCCESS) {
1444-
SET_ERROR_RETURN(Error::InvalidGltf)
1443+
if (nodeObject["rotation"].get_array().get(array) == SUCCESS) {
1444+
auto i = 0U;
1445+
for (auto num : array) {
1446+
double val;
1447+
if (num.get_double().get(val) != SUCCESS) {
1448+
SET_ERROR_RETURN(Error::InvalidGltf)
1449+
}
1450+
node.transform.trs.rotation[i] = static_cast<float>(val);
1451+
++i;
14451452
}
1446-
node.rotation[i] = static_cast<float>(val);
1447-
++i;
1453+
} else {
1454+
node.transform.trs.rotation = {0.0f, 0.0f, 0.0f, 1.0f};
14481455
}
1449-
} else {
1450-
node.rotation = {0.0f, 0.0f, 0.0f, 1.0f};
14511456
}
14521457

14531458
// name is optional.

src/fastgltf_parser.hpp

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,14 @@ namespace fastgltf {
114114
* like DirectStorage or Metal IO.
115115
*/
116116
LoadExternalBuffers = 1 << 4,
117+
118+
/**
119+
* This option makes fastgltf automatically decompose the transformation matrices of nodes
120+
* into the translation, rotation, and scale components. This might be useful to have only
121+
* TRS components, instead of matrices or TRS, which should simplify working with nodes,
122+
* especially with animations.
123+
*/
124+
DecomposeNodeMatrices = 1 << 5,
117125
};
118126
// clang-format on
119127

src/fastgltf_types.hpp

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -327,12 +327,20 @@ namespace fastgltf {
327327
std::optional<size_t> cameraIndex;
328328
std::vector<size_t> children;
329329

330+
union {
331+
struct {
332+
std::array<float, 3> translation;
333+
std::array<float, 4> rotation;
334+
std::array<float, 3> scale;
335+
} trs;
336+
/**
337+
* Ordinary transformation matrix, which cannot skew or shear. Using
338+
* Options::DecomposeNodeMatrices all parsed matrices will be decomposed
339+
* into the TRS components found above.
340+
*/
341+
std::array<float, 16> matrix;
342+
} transform;
330343
bool hasMatrix = false;
331-
std::array<float, 16> matrix;
332-
333-
std::array<float, 3> scale;
334-
std::array<float, 3> translation;
335-
std::array<float, 4> rotation;
336344

337345
std::string name;
338346
};

src/fastgltf_util.hpp

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,46 @@ namespace fastgltf {
2626
return base - (base % alignment);
2727
}
2828

29+
/**
30+
* Decomposes a transform matrix into the translation, rotation, and scale components. This
31+
* function does not support skew, shear, or perspective. This currently uses a quick algorithm
32+
* to calculate the quaternion from the rotation matrix, which might occasionally loose some
33+
* precision, though we try to use doubles here.
34+
*/
35+
inline void decomposeTransformMatrix(std::array<float, 16> matrix, std::array<float, 3>& scale, std::array<float, 4>& rotation, std::array<float, 3>& translation) {
36+
// Extract the translation. We zero the translation out, as we reuse the matrix as
37+
// the rotation matrix at the end.
38+
translation = {matrix[12], matrix[13], matrix[14]};
39+
matrix[12] = matrix[13] = matrix[14] = 0;
40+
41+
// Extract the scale. We calculate the euclidean length of the columns. We then
42+
// construct a vector with those lengths.
43+
auto s1 = std::sqrtf(matrix[0] * matrix[0] + matrix[1] * matrix[1] + matrix[2] * matrix[2]);
44+
auto s2 = std::sqrtf(matrix[4] * matrix[4] + matrix[5] * matrix[5] + matrix[6] * matrix[6]);
45+
auto s3 = std::sqrtf(matrix[8] * matrix[8] + matrix[9] * matrix[9] + matrix[10] * matrix[10]);
46+
scale = {s1, s2, s3};
47+
48+
// Remove the scaling from the matrix, leaving only the rotation. matrix is now the
49+
// rotation matrix.
50+
matrix[0] /= s1; matrix[1] /= s1; matrix[2] /= s1;
51+
matrix[4] /= s2; matrix[5] /= s2; matrix[6] /= s2;
52+
matrix[8] /= s3; matrix[9] /= s3; matrix[10] /= s3;
53+
54+
// Construct the quaternion. This algo is copied from here:
55+
// https://www.euclideanspace.com/maths/geometry/rotations/conversions/matrixToQuaternion/christian.htm.
56+
// glTF orders the components as x,y,z,w
57+
auto max = [](float a, float b) -> double { return (a > b) ? a : b; };
58+
rotation = {
59+
static_cast<float>(std::sqrt(max(0, 1 + matrix[0] - matrix[5] - matrix[10])) / 2),
60+
static_cast<float>(std::sqrt(max(0, 1 - matrix[0] + matrix[5] - matrix[10])) / 2),
61+
static_cast<float>(std::sqrt(max(0, 1 - matrix[0] - matrix[5] + matrix[10])) / 2),
62+
static_cast<float>(std::sqrt(max(0, 1 + matrix[0] + matrix[5] + matrix[10])) / 2),
63+
};
64+
rotation[0] = std::copysignf(rotation[0], matrix[6] - matrix[9]);
65+
rotation[1] = std::copysignf(rotation[1], matrix[8] - matrix[2]);
66+
rotation[2] = std::copysignf(rotation[2], matrix[1] - matrix[4]);
67+
}
68+
2969
static constexpr std::array<uint32_t, 256> crcHashTable = {
3070
0x00000000, 0x77073096, 0xee0e612c, 0x990951ba, 0x076dc419, 0x706af48f,
3171
0xe963a535, 0x9e6495a3, 0x0edb8832, 0x79dcb8a4, 0xe0d5e91e, 0x97d2d988,

tests/CMakeLists.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ set_directory_properties(PROPERTIES EXCLUDE_FROM_ALL TRUE)
33
# We want these tests to be a optional executable.
44
add_executable(tests EXCLUDE_FROM_ALL)
55
target_compile_features(tests PRIVATE cxx_std_20)
6-
target_link_libraries(tests PRIVATE fastgltf)
6+
target_link_libraries(tests PRIVATE fastgltf glm::glm)
77
compiler_flags(TARGET tests)
88

99
if (EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/deps/catch2")

tests/basic_test.cpp

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22
#include <catch2/catch_test_macros.hpp>
33
#include <catch2/benchmark/catch_benchmark.hpp>
44

5+
#include <glm/glm.hpp>
6+
#include <glm/gtc/type_ptr.hpp>
7+
#include <glm/gtx/quaternion.hpp>
8+
#include <glm/gtx/matrix_decompose.hpp>
9+
510
#include "fastgltf_parser.hpp"
611
#include "fastgltf_types.hpp"
712

@@ -327,3 +332,87 @@ TEST_CASE("Test allocation callbacks for embedded buffers", "[gltf-loader]") {
327332
std::free(allocation);
328333
}
329334
}
335+
336+
TEST_CASE("Test TRS parsing and optional decomposition", "[gltf-loader]") {
337+
SECTION("Test decomposition on glTF asset") {
338+
auto jsonData = std::make_unique<fastgltf::JsonData>(path / "transform_matrices.gltf");
339+
340+
// Parse once without decomposing, once with decomposing the matrix.
341+
fastgltf::Parser parser;
342+
auto modelWithMatrix = parser.loadGLTF(jsonData.get(), path);
343+
REQUIRE(parser.getError() == fastgltf::Error::None);
344+
REQUIRE(modelWithMatrix != nullptr);
345+
346+
REQUIRE(modelWithMatrix->parse(fastgltf::Category::Nodes) == fastgltf::Error::None);
347+
auto assetWithMatrix = modelWithMatrix->getParsedAsset();
348+
349+
auto modelDecomposed = parser.loadGLTF(jsonData.get(), path, fastgltf::Options::DecomposeNodeMatrices);
350+
REQUIRE(parser.getError() == fastgltf::Error::None);
351+
REQUIRE(modelWithMatrix != nullptr);
352+
353+
REQUIRE(modelDecomposed->parse(fastgltf::Category::Nodes) == fastgltf::Error::None);
354+
auto assetDecomposed = modelDecomposed->getParsedAsset();
355+
356+
REQUIRE(assetWithMatrix->cameras.size() == 1);
357+
REQUIRE(assetDecomposed->cameras.size() == 1);
358+
REQUIRE(assetWithMatrix->nodes.size() == 2);
359+
REQUIRE(assetDecomposed->nodes.size() == 2);
360+
REQUIRE(assetWithMatrix->nodes.back().hasMatrix);
361+
REQUIRE(!assetDecomposed->nodes.back().hasMatrix);
362+
363+
// Get the TRS components from the first node and use them as the test data for decomposing.
364+
auto translation = glm::make_vec3(assetWithMatrix->nodes.front().transform.trs.translation.data());
365+
auto rotation = glm::make_quat(assetWithMatrix->nodes.front().transform.trs.rotation.data());
366+
auto scale = glm::make_vec3(assetWithMatrix->nodes.front().transform.trs.scale.data());
367+
auto rotationMatrix = glm::toMat4(rotation);
368+
auto transform = glm::translate(glm::mat4(1.0f), translation) * rotationMatrix * glm::scale(glm::mat4(1.0f), scale);
369+
370+
// Check if the parsed matrix is correct.
371+
REQUIRE(glm::make_mat4x4(assetWithMatrix->nodes.back().transform.matrix.data()) == transform);
372+
373+
// Check if the decomposed components equal the original components.
374+
REQUIRE(glm::make_vec3(assetDecomposed->nodes.back().transform.trs.translation.data()) == translation);
375+
REQUIRE(glm::make_quat(assetDecomposed->nodes.back().transform.trs.rotation.data()) == rotation);
376+
REQUIRE(glm::make_vec3(assetDecomposed->nodes.back().transform.trs.scale.data()) == scale);
377+
}
378+
379+
SECTION("Test decomposition against glm decomposition") {
380+
// Some random complex transform matrix from one of the glTF sample models.
381+
std::array<float, 16> matrix = {
382+
-0.4234085381031037,
383+
-0.9059388637542724,
384+
-7.575183536001616e-11,
385+
0.0,
386+
-0.9059388637542724,
387+
0.4234085381031037,
388+
-4.821281221478735e-11,
389+
0.0,
390+
7.575183536001616e-11,
391+
4.821281221478735e-11,
392+
-1.0,
393+
0.0,
394+
-90.59386444091796,
395+
-24.379817962646489,
396+
-40.05522918701172,
397+
1.0
398+
};
399+
400+
std::array<float, 3> translation = {}, scale = {};
401+
std::array<float, 4> rotation = {};
402+
fastgltf::decomposeTransformMatrix(matrix, scale, rotation, translation);
403+
404+
auto glmMatrix = glm::make_mat4x4(matrix.data());
405+
glm::vec3 glmScale, glmTranslation, glmSkew;
406+
glm::quat glmRotation;
407+
glm::vec4 glmPerspective;
408+
glm::decompose(glmMatrix, glmScale, glmRotation, glmTranslation, glmSkew, glmPerspective);
409+
410+
// I use glm::epsilon<float>() * 10 here because some matrices I tested this with resulted
411+
// in an error margin greater than the normal epsilon value. I will investigate this in the
412+
// future, but I suspect using double in the decompose functions should help mitigate most
413+
// of it.
414+
REQUIRE(glm::make_vec3(translation.data()) == glmTranslation);
415+
REQUIRE(glm::all(glm::epsilonEqual(glm::make_quat(rotation.data()), glmRotation, glm::epsilon<float>() * 10)));
416+
REQUIRE(glm::all(glm::epsilonEqual(glm::make_vec3(scale.data()), glmScale, glm::epsilon<float>())));
417+
}
418+
}

tests/gltf/basic_gltf.gltf

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"asset": {
3-
"version": "2.0"
4-
}
2+
"asset": {
3+
"version": "2.0"
4+
}
55
}

tests/gltf/transform_matrices.gltf

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
{
2+
"asset": {
3+
"version": "2.0"
4+
},
5+
"cameras": [
6+
{
7+
"perspective": {
8+
"yfov": 1.0,
9+
"zfar": 1.0,
10+
"znear": 0.001
11+
},
12+
"type": "perspective"
13+
}
14+
],
15+
"nodes": [
16+
{
17+
"name": "TRS components",
18+
"camera": 0,
19+
"translation": [
20+
1.0, 1.0, 1.0
21+
],
22+
"rotation": [
23+
0.0, 1.0, 0.0, 0.0
24+
],
25+
"scale": [
26+
2.0, 0.5, 1.0
27+
]
28+
},
29+
{
30+
"name": "Matrix",
31+
"camera": 0,
32+
"matrix": [
33+
-2.0, 0.0, 0.0, 0.0,
34+
0.0, 0.5, 0.0, 0.0,
35+
0.0, 0.0, -1.0, 0.0,
36+
1.0, 1.0, 1.0, 1.0
37+
]
38+
}
39+
]
40+
}

0 commit comments

Comments
 (0)