From 0fee5544259621be3299dd7de44f456ab32430c9 Mon Sep 17 00:00:00 2001 From: Beherith Date: Tue, 15 Sep 2026 14:25:08 +0200 Subject: [PATCH] Fix GLTF piece axes to match S3O animations (#3321) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * GLTF to Recoil coordinate conversion - Recoil now converts GLTF data at the parser boundary: - Default: (x, y, z) → (x, z, -y) - s3ocompat=true: (x, y, z) → (-x, z, y) - The conversion covers vertices, normals, tangents, node translations, node rotations, nested transforms, and skinned meshes. - The synthetic GLTF root is now identity, so BOS animations operate directly in normal engine/S3O piece axes. - Both mappings are proper rotations; no winding or reflection fix is required. - Existing COB legacy X-move and Z-turn sign behavior remains unchanged. * Fix changelog --- doc/pr-changelogs/3321.md | 3 + .../blender-gltf-import/_index.md | 23 ++++- .../Models/GLTFModelAxisConversion.hpp | 47 ++++++++++ rts/Rendering/Models/GLTFParser.cpp | 93 +++++++++++++------ rts/Rendering/Models/GLTFParser.h | 19 +++- 5 files changed, 149 insertions(+), 36 deletions(-) create mode 100644 doc/pr-changelogs/3321.md create mode 100644 rts/Rendering/Models/GLTFModelAxisConversion.hpp diff --git a/doc/pr-changelogs/3321.md b/doc/pr-changelogs/3321.md new file mode 100644 index 00000000000..54f37c10f8b --- /dev/null +++ b/doc/pr-changelogs/3321.md @@ -0,0 +1,3 @@ +### Caveats + * existing scripts for GLTF/GLB models using the `s3ocompat = true` flag will + see the Y and Z axes swapped for animations, adjust them or set the flag to false. diff --git a/doc/site/content/docs/guides/getting-started/blender-gltf-import/_index.md b/doc/site/content/docs/guides/getting-started/blender-gltf-import/_index.md index 895441a0bc8..aa7d1b5e934 100644 --- a/doc/site/content/docs/guides/getting-started/blender-gltf-import/_index.md +++ b/doc/site/content/docs/guides/getting-started/blender-gltf-import/_index.md @@ -65,7 +65,7 @@ The list of supported key-values is following: | `radius` | `float` | The radius of the model, by default calculated based on the model dimensions | | `fliptextures` | `boolean` | Whether to flip the supplied `tex1` / `tex2` (if supported by the texture types). `False` by default | | `invertteamcolor` | `boolean` | Whether to inverse the teamcolor in `tex1` (if supported by the texture types). `False` by default | -| `s3ocompat` | `boolean` | Transform the model such that its animation and worldspace position matches those of s3o (left-hand coordinate system). `False` by default. Very experimental | +| `s3ocompat` | `boolean` | Select the source-facing convention used by models imported through the S3O Blender workflow. It changes model facing, but both modes load piece data and animation axes into the engine coordinate frame. `False` by default | > [!NOTE] > You can export any additional attributes not listed above, there's no harm in that @@ -98,12 +98,27 @@ In order to load the GLTF model, make sure it resides somewhere in `Objects3d` d ### S3O Compat -If you export your existing s3o model as GLTF and reuse the existing animation, made for s3o model and you see your units are walking "backwards", make sure to instruct the importer to try to rotate the model in s3o compatible way (define `s3ocompat` to `true`). +If you import an existing S3O into Blender and export it as GLTF, define +`s3ocompat` as `true`. This selects the same source-facing conversion as the S3O +Blender tools: Blender `(x, y, z)` becomes engine `(-x, z, y)`. -By default, GLTF is imported in the same (right handed) coordinate system as Collada / `.dae` and thus require changes to the animation scripts. +The default convention preserves the historical GLTF facing conversion +`(x, z, -y)`. Both conventions are baked into vertices, piece offsets, node rest +rotations, normals, and tangents while the model loads. The resulting hierarchy is +always in the engine model coordinate frame. + +BOS and LUS piece animation axes are therefore the same as for S3O. Do not remap +script axes based on whether the model uses `.s3o`, `.gltf`, or `.glb`, and do not +use BARScriptCompiler's deprecated GLTF axis-remapping flags or macros. + +`midpos`, `mins`, and `maxs` metadata values are interpreted directly in engine +model coordinates. They are not converted from Blender's Z-up source frame. ![image](blender-gltf-9.png) ### Nodes hierarchy -The importer will create a few empty top level pieces to accomodate for rotations and other nuances of the importer. This is usually completely transparent to the game devs. This might be optimized later on. +The importer creates an empty scene piece above the exported node hierarchy. The +piece has an identity transform; coordinate conversion is applied to the imported +piece data rather than hidden in this ancestor. Additional empty nodes exported by +Blender remain ordinary model pieces. diff --git a/rts/Rendering/Models/GLTFModelAxisConversion.hpp b/rts/Rendering/Models/GLTFModelAxisConversion.hpp new file mode 100644 index 00000000000..af841c1c52f --- /dev/null +++ b/rts/Rendering/Models/GLTFModelAxisConversion.hpp @@ -0,0 +1,47 @@ +/* This file is part of the Recoil engine (GPL v2 or later), see LICENSE.html */ + +#pragma once + +#include "System/Transform.hpp" + +namespace gltfmodel { + + enum class SourceConvention { + Default, + S3OCompatible, + }; + + constexpr SourceConvention GetSourceConvention(bool s3oCompatible) + { + return s3oCompatible ? SourceConvention::S3OCompatible : SourceConvention::Default; + } + + // Recoil's GLTF pipeline keeps Blender's Z-up coordinates in the file. + // Convert those coordinates into the Y-up engine model frame. The two + // conventions differ only in the source model's forward direction. + constexpr float3 ToEngineSpace(const float3& v, SourceConvention convention) + { + if (convention == SourceConvention::S3OCompatible) + return float3{-v.x, v.z, v.y}; + + return float3{v.x, v.z, -v.y}; + } + + // For a proper basis rotation qBasis, q' = qBasis * q * inverse(qBasis). + // Quaternion conjugation rotates the imaginary part and preserves the real + // part, so the exact signed swizzle avoids needless floating-point noise. + constexpr CQuaternion ToEngineSpace(const CQuaternion& q, SourceConvention convention) + { + const float3 imaginary = ToEngineSpace(float3{q.x, q.y, q.z}, convention); + return CQuaternion{imaginary, q.r}; + } + + constexpr Transform ToEngineSpace(const Transform& transform, SourceConvention convention) + { + return Transform{ + ToEngineSpace(transform.r, convention), + ToEngineSpace(transform.t, convention), + transform.s, + }; + } +} diff --git a/rts/Rendering/Models/GLTFParser.cpp b/rts/Rendering/Models/GLTFParser.cpp index f72b17dabe5..119e1421860 100644 --- a/rts/Rendering/Models/GLTFParser.cpp +++ b/rts/Rendering/Models/GLTFParser.cpp @@ -9,6 +9,7 @@ #include "GLTFParser.h" #include "3DModel.hpp" #include "3DModelLog.h" +#include "GLTFModelAxisConversion.hpp" #include "ModelUtils.h" #include "Lua/LuaParser.h" @@ -42,8 +43,11 @@ namespace Impl { return Transform{ r, t, s.x }; } - Transform GetNodeTransform(const fastgltf::Node& node) { - return fastgltf::visit_exhaustive(fastgltf::visitor{ + Transform GetNodeTransform( + const fastgltf::Node& node, + gltfmodel::SourceConvention sourceConvention + ) { + const Transform sourceTransform = fastgltf::visit_exhaustive(fastgltf::visitor{ [](const fastgltf::math::fmat4x4& matrix) { return Impl::MatrixToTransform(matrix); }, @@ -51,10 +55,22 @@ namespace Impl { return Impl::TRStoTransform(trs); } }, node.transform); + + return gltfmodel::ToEngineSpace(sourceTransform, sourceConvention); } template - void ReadGeometryData(const fastgltf::Asset& asset, const PrimContainer& primitives, std::vector& verts, std::vector& indcs, size_t nodeIdx, const fastgltf::Skin* skinPtr = nullptr) { + void ReadGeometryData( + const fastgltf::Asset& asset, + const PrimContainer& primitives, + std::vector& verts, + std::vector& indcs, + size_t nodeIdx, + gltfmodel::SourceConvention sourceConvention, + const fastgltf::Skin* skinPtr = nullptr + ) { + const size_t firstVertSize = verts.size(); + for (const auto& prim : primitives) { const size_t prevVertSize = verts.size(); const size_t prevIndcSize = indcs.size(); @@ -197,6 +213,15 @@ namespace Impl { if (!seenTangents) ModelUtils::CalculateTangents(verts, indcs); + + } + + for (size_t i = firstVertSize; i < verts.size(); ++i) { + auto& vert = verts[i]; + vert.pos = gltfmodel::ToEngineSpace(vert.pos, sourceConvention); + vert.normal = gltfmodel::ToEngineSpace(vert.normal, sourceConvention); + vert.sTangent = gltfmodel::ToEngineSpace(vert.sTangent, sourceConvention); + vert.tTangent = gltfmodel::ToEngineSpace(vert.tTangent, sourceConvention); } } @@ -312,7 +337,7 @@ namespace Impl { // TODO guess the texture? } - auto GetModelTransforms(const fastgltf::Asset& asset, std::size_t sceneIndex, const Transform& sceneTransform = Transform{}) { + auto GetModelTransforms(const fastgltf::Asset& asset, std::size_t sceneIndex, gltfmodel::SourceConvention sourceConvention) { auto& scene = asset.scenes[sceneIndex]; spring::unordered_map transforms(asset.nodes.size()); @@ -322,7 +347,7 @@ namespace Impl { const auto& [it, noDup] = transforms.emplace( nodeIdx, - parentTransform * GetNodeTransform(node) + parentTransform * GetNodeTransform(node, sourceConvention) ); assert(noDup); @@ -332,7 +357,7 @@ namespace Impl { }; for (auto& node : scene.nodeIndices) { - function(function, node, sceneTransform); + function(function, node, Transform{}); } return transforms; @@ -444,14 +469,16 @@ void CGLTFParser::Load(S3DModel& model, const std::string& modelFilePath) model.mins = DEF_MIN_SIZE; model.maxs = DEF_MAX_SIZE; - // GLTF model MUST be exported with Z axis UP. We will rotate it here by ourselves - const auto initTransform = (optionalModelParams.s3oCompat.value_or(false)) ? - Transform(CQuaternion(0, -math::HALFSQRT2, -math::HALFSQRT2, 0)): // Rotate so xyz ==> (-x,z, y) - Transform(CQuaternion( math::HALFSQRT2, 0, 0, -math::HALFSQRT2)); // Rotate so xyz ==> ( x,z,-y) + // GLTF source coordinates are converted into the engine model frame at the + // parser boundary. Keeping the synthetic scene root unrotated makes script + // animation axes identical to those of S3O pieces. + const auto sourceConvention = gltfmodel::GetSourceConvention( + optionalModelParams.s3oCompat.value_or(false) + ); const auto defaultSceneIdx = asset.defaultScene.value_or(0); - auto* rootPiece = AllocRootEmptyPiece(&model, initTransform, asset, defaultSceneIdx); + auto* rootPiece = AllocRootEmptyPiece(&model, asset, defaultSceneIdx, sourceConvention); model.FlattenPieceTree(rootPiece); spring::unordered_map nodeIdxToPieceIdx; @@ -465,7 +492,7 @@ void CGLTFParser::Load(S3DModel& model, const std::string& modelFilePath) // conceptually almost the same as AllocRootEmptyPiece + SetPieceMatrices // except this one doesn't ignore nodes with skinned meshes - const auto modelTransforms = Impl::GetModelTransforms(asset, defaultSceneIdx, initTransform); + const auto modelTransforms = Impl::GetModelTransforms(asset, defaultSceneIdx, sourceConvention); std::vector allSkinnedMeshes; @@ -481,7 +508,7 @@ void CGLTFParser::Load(S3DModel& model, const std::string& modelFilePath) const auto& mesh = asset.meshes[*node.meshIndex]; auto& skinnedMesh = allSkinnedMeshes.emplace_back(); - Impl::ReadGeometryData(asset, mesh.primitives, skinnedMesh.verts, skinnedMesh.indcs, ni, &skin); + Impl::ReadGeometryData(asset, mesh.primitives, skinnedMesh.verts, skinnedMesh.indcs, ni, sourceConvention, &skin); Impl::TransformSkinsToModelSpace(skinnedMesh.verts, ni, modelTransforms); Impl::ReplaceNodeIndexWithPieceIndex(skinnedMesh.verts, nodeIdxToPieceIdx); } @@ -544,7 +571,12 @@ GLTFPiece* CGLTFParser::AllocPiece() return &piecePool[numPoolPieces++]; } -GLTFPiece* CGLTFParser::AllocRootEmptyPiece(S3DModel* model, const Transform& parentTransform, const fastgltf::Asset& asset, size_t sceneIndex) +GLTFPiece* CGLTFParser::AllocRootEmptyPiece( + S3DModel* model, + const fastgltf::Asset& asset, + size_t sceneIndex, + gltfmodel::SourceConvention sourceConvention +) { const auto& scene = asset.scenes[sceneIndex]; @@ -553,13 +585,9 @@ GLTFPiece* CGLTFParser::AllocRootEmptyPiece(S3DModel* model, const Transform& pa piece->SetParentModel(model); - auto bakedTransform = parentTransform; - // only rotation is allowed because of Spring-isms - bakedTransform.t = float3{}; - bakedTransform.s = 1.0f; - piece->SetBakedTransform(bakedTransform); // bakedRotAngles are not read or supported for GLTF - piece->offset = parentTransform.t; - piece->scale = parentTransform.s; + piece->SetBakedTransform(Transform{}); + piece->offset = float3{}; + piece->scale = 1.0f; piece->parent = nullptr; @@ -567,7 +595,7 @@ GLTFPiece* CGLTFParser::AllocRootEmptyPiece(S3DModel* model, const Transform& pa piece->children.reserve(scene.nodeIndices.size()); for (const auto childNodeIndex : scene.nodeIndices) { - auto* childPiece = LoadPiece(model, piece, asset, childNodeIndex); + auto* childPiece = LoadPiece(model, piece, asset, childNodeIndex, sourceConvention); if (childPiece) piece->children.push_back(childPiece); } @@ -576,7 +604,13 @@ GLTFPiece* CGLTFParser::AllocRootEmptyPiece(S3DModel* model, const Transform& pa } -GLTFPiece* CGLTFParser::LoadPiece(S3DModel* model, GLTFPiece* parentPiece, const fastgltf::Asset& asset, size_t nodeIndex) +GLTFPiece* CGLTFParser::LoadPiece( + S3DModel* model, + GLTFPiece* parentPiece, + const fastgltf::Asset& asset, + size_t nodeIndex, + gltfmodel::SourceConvention sourceConvention +) { const auto& node = asset.nodes[nodeIndex]; @@ -593,19 +627,20 @@ GLTFPiece* CGLTFParser::LoadPiece(S3DModel* model, GLTFPiece* parentPiece, const piece->children.reserve(node.children.size()); piece->nodeIndex = nodeIndex; - Transform pieceTransform = Impl::GetNodeTransform(node); + Transform pieceTransform = Impl::GetNodeTransform(node, sourceConvention); auto bakedTransform = pieceTransform; - // by idiotic Spring convention bakedTransform should only contain rotation + // Piece translation and scale are stored separately; bakedTransform contains + // only the artist-authored rest rotation expressed in engine axes. bakedTransform.t = float3{}; bakedTransform.s = 1.0f; - piece->SetBakedTransform(bakedTransform); // bakedRotAngles are not read or supported for GLTF + piece->SetBakedTransform(bakedTransform); piece->offset = pieceTransform.t; piece->scale = pieceTransform.s; for (const auto childNodeIndex : node.children) { - auto* childPiece = LoadPiece(model, piece, asset, childNodeIndex); + auto* childPiece = LoadPiece(model, piece, asset, childNodeIndex, sourceConvention); if (childPiece) piece->children.push_back(childPiece); } @@ -616,7 +651,7 @@ GLTFPiece* CGLTFParser::LoadPiece(S3DModel* model, GLTFPiece* parentPiece, const auto& verts = piece->GetVerticesVec(); auto& indcs = piece->GetIndicesVec(); const auto& mesh = asset.meshes[*node.meshIndex]; - Impl::ReadGeometryData(asset, mesh.primitives, verts, indcs, nodeIndex, nullptr); + Impl::ReadGeometryData(asset, mesh.primitives, verts, indcs, nodeIndex, sourceConvention, nullptr); return piece; -} \ No newline at end of file +} diff --git a/rts/Rendering/Models/GLTFParser.h b/rts/Rendering/Models/GLTFParser.h index 50be1074392..0c9138f7ac6 100644 --- a/rts/Rendering/Models/GLTFParser.h +++ b/rts/Rendering/Models/GLTFParser.h @@ -14,7 +14,9 @@ namespace fastgltf { class Asset; } -struct Transform; +namespace gltfmodel { + enum class SourceConvention; +} struct GLTFPiece : public S3DModelPiece { static constexpr size_t INVALID_NODE_INDEX = size_t(-1); @@ -30,8 +32,19 @@ class CGLTFParser: public IModelParser void Load(S3DModel& model, const std::string& name) override; private: GLTFPiece* AllocPiece(); - GLTFPiece* AllocRootEmptyPiece(S3DModel* model, const Transform& parentTransform, const fastgltf::Asset& asset, size_t sceneIndex); - GLTFPiece* LoadPiece(S3DModel* model, GLTFPiece* parentPiece, const fastgltf::Asset& asset, size_t nodeIndex); + GLTFPiece* AllocRootEmptyPiece( + S3DModel* model, + const fastgltf::Asset& asset, + size_t sceneIndex, + gltfmodel::SourceConvention sourceConvention + ); + GLTFPiece* LoadPiece( + S3DModel* model, + GLTFPiece* parentPiece, + const fastgltf::Asset& asset, + size_t nodeIndex, + gltfmodel::SourceConvention sourceConvention + ); std::vector piecePool; spring::mutex poolMutex;