feat: add basic format for models
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
|
||||
#include <destrum/Event.h>
|
||||
#include <destrum/Assets/AssetReference.h>
|
||||
#include <destrum/Assets/ModelFile.h>
|
||||
#include <destrum/FS/AssetFS.h>
|
||||
#include <destrum/ObjectModel/Component.h>
|
||||
#include <destrum/ObjectModel/GameObject.h>
|
||||
@@ -485,6 +486,189 @@ namespace {
|
||||
"LightComponent range must stay positive");
|
||||
}
|
||||
|
||||
ModelDoc::ModelAsset makeTestModelAsset()
|
||||
{
|
||||
Skeleton skeleton;
|
||||
skeleton.assetReference = {.path = "engine://character.fbx", .subresource = "skeleton"};
|
||||
skeleton.hierarchy = {{.id = 0, .children = {1}}, {.id = 1, .children = {}}};
|
||||
skeleton.inverseBindMatrices = {glm::mat4{1.0f}, glm::mat4{2.0f}};
|
||||
skeleton.joints = {
|
||||
{.id = 0, .localTranslation = glm::vec3{0.0f, 1.0f, 0.0f}},
|
||||
{.id = 1, .localScale = glm::vec3{2.0f}},
|
||||
};
|
||||
skeleton.jointNames = {"root", "spine"};
|
||||
buildParentIndex(skeleton);
|
||||
|
||||
CPUMesh mesh;
|
||||
mesh.indices = {0, 1, 2};
|
||||
mesh.vertices = {
|
||||
{.position = glm::vec3{0.0f}, .uv_x = 0.25f, .normal = glm::vec3{0.0f, 1.0f, 0.0f}},
|
||||
{.position = glm::vec3{1.0f, 0.0f, 0.0f}, .tangent = glm::vec4{1.0f, 0.0f, 0.0f, -1.0f}},
|
||||
{.position = glm::vec3{0.0f, 0.0f, 1.0f}, .uv_y = 0.75f},
|
||||
};
|
||||
mesh.skinningData = {
|
||||
{.jointIds = glm::uvec4{0, 1, 0, 0}, .weights = glm::vec4{0.5f, 0.5f, 0.0f, 0.0f}},
|
||||
{.jointIds = glm::uvec4{1, 0, 0, 0}, .weights = glm::vec4{1.0f, 0.0f, 0.0f, 0.0f}},
|
||||
{.jointIds = glm::uvec4{0, 0, 0, 0}, .weights = glm::vec4{1.0f, 0.0f, 0.0f, 0.0f}},
|
||||
};
|
||||
mesh.name = "body";
|
||||
mesh.assetReference = {.path = "engine://character.fbx", .subresource = "mesh:0"};
|
||||
mesh.minPos = glm::vec3{0.0f};
|
||||
mesh.maxPos = glm::vec3{1.0f};
|
||||
mesh.skeleton = skeleton;
|
||||
|
||||
ModelDoc::MeshPrimitive primitive;
|
||||
primitive.mesh = mesh;
|
||||
primitive.materialIndex = 0;
|
||||
primitive.nodeName = "Body";
|
||||
primitive.sourceMeshIndex = 3;
|
||||
primitive.skinned = true;
|
||||
primitive.nodeWorldTransform = glm::mat4{3.0f};
|
||||
|
||||
ModelDoc::MaterialInfo material;
|
||||
material.name = "skin";
|
||||
material.assetReference = {.path = "engine://character.fbx", .subresource = "material:0"};
|
||||
material.baseColor = glm::vec4{0.8f, 0.6f, 0.4f, 1.0f};
|
||||
material.emissiveColor = glm::vec3{0.1f};
|
||||
material.metallicFactor = 0.2f;
|
||||
material.roughnessFactor = 0.9f;
|
||||
material.opacity = 0.5f;
|
||||
material.textures = {{
|
||||
.semantic = ModelDoc::TextureSemantic::BaseColor,
|
||||
.rawPath = "*0",
|
||||
.path = "textures/skin.png",
|
||||
.assimpType = aiTextureType_DIFFUSE,
|
||||
.slot = 1,
|
||||
.embedded = true,
|
||||
}};
|
||||
|
||||
SkeletalAnimation animation;
|
||||
animation.assetReference = {.path = "engine://character.fbx", .subresource = "animation:0"};
|
||||
animation.tracks = {{
|
||||
.jointIndex = 1,
|
||||
.keyframes = {
|
||||
{.time = 0.0f, .translation = glm::vec3{0.0f}, .rotation = glm::identity<glm::quat>(), .scale = glm::vec3{1.0f}},
|
||||
{.time = 0.5f, .translation = glm::vec3{0.0f, 2.0f, 0.0f}, .rotation = glm::quat{0.0f, 1.0f, 0.0f, 0.0f}, .scale = glm::vec3{1.0f}},
|
||||
},
|
||||
}};
|
||||
animation.duration = 0.5f;
|
||||
animation.looped = false;
|
||||
animation.name = "wave";
|
||||
animation.startFrame = 4;
|
||||
animation.events = {{2, {"footstep", "dust"}}};
|
||||
|
||||
ModelDoc::ModelAsset model;
|
||||
model.sourcePath = "engine://character.fbx";
|
||||
model.primitives = {primitive};
|
||||
model.materials = {material};
|
||||
model.skeleton = skeleton;
|
||||
model.animations = {animation};
|
||||
return model;
|
||||
}
|
||||
|
||||
bool modelFileReadThrows(const std::vector<std::uint8_t>& bytes)
|
||||
{
|
||||
try {
|
||||
(void)ModelFile::Read(bytes);
|
||||
} catch (const std::exception&) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void testModelFileRoundTrip()
|
||||
{
|
||||
const auto bytes = ModelFile::Write(makeTestModelAsset());
|
||||
const auto loaded = ModelFile::Read(bytes);
|
||||
|
||||
check(ModelFile::Write(loaded) == bytes,
|
||||
"a cooked model must write back to exactly the same bytes");
|
||||
|
||||
check(loaded.sourcePath == "engine://character.fbx",
|
||||
"cooked models must keep their source path");
|
||||
|
||||
check(loaded.primitives.size() == 1, "cooked models must keep their primitives");
|
||||
const auto& primitive = loaded.primitives.front();
|
||||
check(primitive.nodeName == "Body" && primitive.sourceMeshIndex == 3 && primitive.skinned,
|
||||
"cooked primitives must keep their import metadata");
|
||||
check(primitive.mesh.indices == std::vector<std::uint32_t>{0, 1, 2},
|
||||
"cooked meshes must keep their indices");
|
||||
check(primitive.mesh.vertices.size() == 3 &&
|
||||
primitive.mesh.vertices[0].uv_x == 0.25f &&
|
||||
primitive.mesh.vertices[1].tangent.w == -1.0f,
|
||||
"cooked meshes must keep their vertices");
|
||||
check(primitive.mesh.skinningData[0].jointIds.y == 1 &&
|
||||
primitive.mesh.skinningData[0].weights.x == 0.5f,
|
||||
"cooked meshes must keep their skinning data");
|
||||
check(primitive.mesh.skeleton.has_value() &&
|
||||
primitive.mesh.skeleton->jointNames.size() == 2,
|
||||
"cooked skinned meshes must keep their skeleton");
|
||||
|
||||
check(loaded.materials.size() == 1 && loaded.materials.front().opacity == 0.5f,
|
||||
"cooked models must keep their materials");
|
||||
const auto& texture = loaded.materials.front().textures.front();
|
||||
check(texture.semantic == ModelDoc::TextureSemantic::BaseColor &&
|
||||
texture.path == "textures/skin.png" &&
|
||||
texture.assimpType == aiTextureType_DIFFUSE &&
|
||||
texture.embedded,
|
||||
"cooked materials must keep their textures");
|
||||
|
||||
check(loaded.skeleton.jointNames == std::vector<std::string>{"root", "spine"} &&
|
||||
loaded.skeleton.parentIndex == std::vector<int>{-1, 0} &&
|
||||
loaded.skeleton.joints[1].localScale == glm::vec3{2.0f},
|
||||
"cooked models must keep their skeleton");
|
||||
|
||||
check(loaded.animations.size() == 1, "cooked models must keep their animations");
|
||||
const auto& animation = loaded.animations.front();
|
||||
check(animation.name == "wave" && !animation.looped && animation.startFrame == 4,
|
||||
"cooked animations must keep their settings");
|
||||
check(animation.tracks.front().keyframes[1].translation.y == 2.0f &&
|
||||
animation.tracks.front().keyframes[1].rotation.x == 1.0f,
|
||||
"cooked animations must keep their keyframes");
|
||||
check(animation.events.at(2) == std::vector<std::string>{"footstep", "dust"},
|
||||
"cooked animations must keep their events");
|
||||
}
|
||||
|
||||
void testModelFileRejectsBadData()
|
||||
{
|
||||
const auto bytes = ModelFile::Write(makeTestModelAsset());
|
||||
|
||||
auto truncated = bytes;
|
||||
truncated.resize(bytes.size() / 2);
|
||||
check(modelFileReadThrows(truncated), "truncated cooked models must be rejected");
|
||||
|
||||
auto wrongMagic = bytes;
|
||||
wrongMagic[0] = 'X';
|
||||
check(modelFileReadThrows(wrongMagic), "files that aren't cooked models must be rejected");
|
||||
|
||||
auto wrongVersion = bytes;
|
||||
wrongVersion[4] = static_cast<std::uint8_t>(wrongVersion[4] + 1);
|
||||
check(modelFileReadThrows(wrongVersion), "cooked models from another version must be rejected");
|
||||
|
||||
auto trailingData = bytes;
|
||||
trailingData.push_back(0);
|
||||
check(modelFileReadThrows(trailingData), "cooked models with trailing data must be rejected");
|
||||
}
|
||||
|
||||
void testModelFileFromImportedModel()
|
||||
{
|
||||
const auto source = std::filesystem::path{DESTRUM_ENGINE_ASSETS_SRC_DIR} / "sphere.fbx";
|
||||
const auto imported = ModelDoc::LoadModel(source.generic_string());
|
||||
|
||||
const auto path = std::filesystem::temp_directory_path() / "destrum_model_test.dmodel";
|
||||
ModelFile::WriteToFile(imported, path);
|
||||
const auto loaded = ModelFile::ReadFromFile(path);
|
||||
std::filesystem::remove(path);
|
||||
|
||||
check(!imported.primitives.empty(), "the test model must import with at least one primitive");
|
||||
check(loaded.primitives.size() == imported.primitives.size() &&
|
||||
loaded.primitives.front().mesh.vertices.size() == imported.primitives.front().mesh.vertices.size() &&
|
||||
loaded.primitives.front().mesh.indices == imported.primitives.front().mesh.indices,
|
||||
"an imported model must survive being cooked");
|
||||
check(ModelFile::Write(loaded) == ModelFile::Write(imported),
|
||||
"a cooked imported model must match the import exactly");
|
||||
}
|
||||
|
||||
void testSimpleColorMaterial()
|
||||
{
|
||||
const auto material = Material::SimpleColor(
|
||||
@@ -600,6 +784,9 @@ int main()
|
||||
testSceneJoltSphereCollisions();
|
||||
testAnimatorAssetReferences();
|
||||
testLightComponentRoundTrip();
|
||||
testModelFileRoundTrip();
|
||||
testModelFileRejectsBadData();
|
||||
testModelFileFromImportedModel();
|
||||
testSimpleColorMaterial();
|
||||
testAssetReferenceCacheKeys();
|
||||
testTriggerZone();
|
||||
|
||||
Reference in New Issue
Block a user