From bc0a786444dce7438227d2c9fe59e5ca95137013 Mon Sep 17 00:00:00 2001 From: mo Date: Sun, 18 Oct 2026 21:07:05 +0100 Subject: [PATCH] fix: correct XModel tangent vectors and handedness (#1046) Model loading used binormals as tangents and derived handedness from the binormal's X component. Use the calculated tangent and basis orientation --- .../XModel/LoaderXModel.cpp.template | 7 +- .../Game/IW4/XModelTangentsIW4.cpp | 65 +++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 test/SystemTests/Game/IW4/XModelTangentsIW4.cpp diff --git a/src/ObjLoading/XModel/LoaderXModel.cpp.template b/src/ObjLoading/XModel/LoaderXModel.cpp.template index 3f8477f2..53fb6475 100644 --- a/src/ObjLoading/XModel/LoaderXModel.cpp.template +++ b/src/ObjLoading/XModel/LoaderXModel.cpp.template @@ -490,7 +490,10 @@ namespace vertex.xyz.x = commonVertex.coordinates[0]; vertex.xyz.y = commonVertex.coordinates[1]; vertex.xyz.z = commonVertex.coordinates[2]; - vertex.binormalSign = binormal[0] > 0.0f ? 1.0f : -1.0f; + const Eigen::Vector3f normal(commonVertex.normal[0], commonVertex.normal[1], commonVertex.normal[2]); + const Eigen::Vector3f tangentVector(tangent[0], tangent[1], tangent[2]); + const Eigen::Vector3f binormalVector(binormal[0], binormal[1], binormal[2]); + vertex.binormalSign = normal.cross(tangentVector).dot(binormalVector) < 0.0f ? -1.0f : 1.0f; vertex.color = Common::Vec4PackGfxColor(commonVertex.color); vertex.texCoord = Common::Vec2PackTexCoords(commonVertex.uv); vertex.normal = Common::Vec3PackUnitVec(commonVertex.normal); @@ -901,7 +904,7 @@ namespace { const auto commonVertexIndex = xmodelToCommonVertexIndexLookup[vertexIndex]; const auto& commonVertex = common.m_vertices[commonVertexIndex]; - CreateVertex(surface.verts0[vertexIndex], commonVertex, tangentData.m_binormals[commonVertexIndex], tangentData.m_binormals[commonVertexIndex]); + CreateVertex(surface.verts0[vertexIndex], commonVertex, tangentData.m_tangents[commonVertexIndex], tangentData.m_binormals[commonVertexIndex]); } if (!common.m_bone_weight_data.weights.empty()) diff --git a/test/SystemTests/Game/IW4/XModelTangentsIW4.cpp b/test/SystemTests/Game/IW4/XModelTangentsIW4.cpp new file mode 100644 index 00000000..13f6ccd1 --- /dev/null +++ b/test/SystemTests/Game/IW4/XModelTangentsIW4.cpp @@ -0,0 +1,65 @@ +#include "Game/IW4/CommonIW4.h" +#include "Gdt/GdtLookup.h" +#include "IObjLoader.h" +#include "SearchPath/MockSearchPath.h" +#include "XModel/Gltf/GltfBinOutput.h" +#include "XModel/Gltf/GltfWriter.h" +#include "XModel/XModelCommon.h" + +#include +#include +#include +#include +#include + +namespace +{ + TEST_CASE("IW4 model loading preserves tangent axes and mirrored UV handedness", "[iw4][system][xmodel]") + { + const auto mirrored = GENERATE(false, true); + XModelCommon common; + common.m_name = "triangle"; + common.m_vertices = { + {.coordinates = {0, 0, 0}, .normal = {0, 0, 1}, .color = {1, 1, 1, 1}, .uv = {0, 0} }, + {.coordinates = {1, 0, 0}, .normal = {0, 0, 1}, .color = {1, 1, 1, 1}, .uv = {1, 0} }, + {.coordinates = {0, 1, 0}, .normal = {0, 0, 1}, .color = {1, 1, 1, 1}, .uv = {0, mirrored ? -1.0f : 1.0f}}, + }; + XModelMaterial material{}; + material.ApplyDefaults(); + material.name = "material"; + common.m_materials.push_back(material); + common.m_objects.push_back({.name = "triangle", .materialIndex = 0, .m_faces = {XModelFace{{0, 1, 2}}}}); + std::ostringstream modelData(std::ios::binary); + const gltf::BinOutput modelOutput(modelData); + gltf::Writer::CreateWriter(&modelOutput, "iw4", "test")->Write(common); + const nlohmann::json metadata{ + {"_game", "iw4" }, + {"_type", "xmodel" }, + {"_version", 2 }, + {"flags", 0 }, + {"lods", {{{"file", "triangle.glb"}, {"distance", 100}}}}, + }; + MockSearchPath searchPath; + searchPath.AddFileData("triangle.glb", modelData.str()); + searchPath.AddFileData("xmodel/triangle.json", metadata.dump()); + searchPath.AddFileData("partclassification.csv", ""); + Zone zone("tangents", 0, GameId::IW4, GamePlatform::PC); + AssetCreatorCollection creators(zone); + GdtLookup gdt; + IObjLoader::GetObjLoaderForGame(GameId::IW4)->ConfigureCreatorCollection(creators, zone, searchPath, gdt); + IgnoredAssetLookup ignored; + ignored.m_ignored_asset_lookup.emplace("material", IW4::ASSET_TYPE_MATERIAL); + AssetCreationContext context(zone, &creators, &ignored); + REQUIRE(creators.CreateAsset(IW4::ASSET_TYPE_XMODEL, "triangle", context).HasBeenSuccessful()); + for (auto i = 0u; i < 3u; ++i) + { + float tangent[3]; + const auto& vertex = zone.m_pools.GetAsset("triangle")->Asset()->lodInfo[0].modelSurfs->surfs[0].verts0[i]; + IW4::Common::Vec3UnpackUnitVec(vertex.tangent, tangent); + CHECK_THAT(tangent[0], Catch::Matchers::WithinAbs(1.0f, 0.01f)); + CHECK_THAT(tangent[1], Catch::Matchers::WithinAbs(0.0f, 0.01f)); + CHECK_THAT(tangent[2], Catch::Matchers::WithinAbs(0.0f, 0.01f)); + CHECK(vertex.binormalSign == (mirrored ? -1.0f : 1.0f)); + } + } +} // namespace