diff --git a/Axiom/Assets/AssetCookManifest.cpp b/Axiom/Assets/AssetCookManifest.cpp new file mode 100644 index 00000000..e46611d8 --- /dev/null +++ b/Axiom/Assets/AssetCookManifest.cpp @@ -0,0 +1,323 @@ +#include "AssetCookManifest.h" + +#include "Core/Log.h" + +#include +#include +#include +#include +#include + +namespace Axiom::Assets { +namespace { + +std::string EscapeJson(std::string_view Value) { + std::string Out; + Out.reserve(Value.size() + 2); + Out += '"'; + for (char Character : Value) { + if (Character == '"') { + Out += "\\\""; + } else if (Character == '\\') { + Out += "\\\\"; + } else if (Character == '\n') { + Out += "\\n"; + } else { + Out += Character; + } + } + Out += '"'; + return Out; +} + +const char *AssetKindToString(AssetKind Kind) { + switch (Kind) { + case AssetKind::Mesh: + return "mesh"; + case AssetKind::Texture: + return "texture"; + case AssetKind::Material: + return "material"; + default: + return "unknown"; + } +} + +AssetKind AssetKindFromString(std::string_view Value) { + if (Value == "mesh") + return AssetKind::Mesh; + if (Value == "texture") + return AssetKind::Texture; + if (Value == "material") + return AssetKind::Material; + return AssetKind::Unknown; +} + +struct Parser { + std::string_view Src; + size_t Pos{0}; + + char Peek() const { return Pos < Src.size() ? Src[Pos] : '\0'; } + + void SkipWs() { + while (Pos < Src.size() && + std::isspace(static_cast(Src[Pos])) != 0) { + ++Pos; + } + } + + bool Expect(char Character) { + SkipWs(); + if (Peek() != Character) + return false; + ++Pos; + return true; + } + + std::optional ParseString() { + SkipWs(); + if (Peek() != '"') + return std::nullopt; + ++Pos; + + std::string Out; + while (Pos < Src.size()) { + const char Character = Src[Pos++]; + if (Character == '"') + return Out; + if (Character == '\\') { + if (Pos >= Src.size()) + return std::nullopt; + const char Escaped = Src[Pos++]; + if (Escaped == 'n') { + Out += '\n'; + } else { + Out += Escaped; + } + } else { + Out += Character; + } + } + + return std::nullopt; + } + + std::optional ParseUint64() { + SkipWs(); + const size_t Start = Pos; + while (Pos < Src.size() && + std::isdigit(static_cast(Src[Pos])) != 0) { + ++Pos; + } + if (Start == Pos) + return std::nullopt; + + uint64_t Value = 0; + for (size_t Index = Start; Index < Pos; ++Index) { + Value = Value * 10u + static_cast(Src[Index] - '0'); + } + return Value; + } + + void SkipValue() { + SkipWs(); + const char Character = Peek(); + if (Character == '"') { + ParseString(); + return; + } + if (Character == '{') { + SkipObject(); + return; + } + if (Character == '[') { + SkipArray(); + return; + } + if (Src.substr(Pos, 4) == "null") { + Pos += 4; + return; + } + ParseUint64(); + } + + void SkipObject() { + if (!Expect('{')) + return; + SkipWs(); + if (Peek() == '}') { + ++Pos; + return; + } + do { + ParseString(); + Expect(':'); + SkipValue(); + SkipWs(); + } while (Expect(',')); + Expect('}'); + } + + void SkipArray() { + if (!Expect('[')) + return; + SkipWs(); + if (Peek() == ']') { + ++Pos; + return; + } + do { + SkipValue(); + SkipWs(); + } while (Expect(',')); + Expect(']'); + } + + template bool ParseObject(HandlerFn Handler) { + if (!Expect('{')) + return false; + SkipWs(); + if (Peek() == '}') { + ++Pos; + return true; + } + + do { + auto Key = ParseString(); + if (!Key.has_value()) + return false; + if (!Expect(':')) + return false; + if (!Handler(*Key)) + SkipValue(); + SkipWs(); + } while (Expect(',')); + + return Expect('}'); + } + + template bool ParseArray(HandlerFn Handler) { + if (!Expect('[')) + return false; + SkipWs(); + if (Peek() == ']') { + ++Pos; + return true; + } + + do { + if (!Handler()) + return false; + SkipWs(); + } while (Expect(',')); + + return Expect(']'); + } +}; + +} // namespace + +std::optional +LoadAssetCookManifest(const std::filesystem::path &Path) { + std::ifstream File(Path); + if (!File.is_open()) { + return std::nullopt; + } + + const std::string Text((std::istreambuf_iterator(File)), + std::istreambuf_iterator()); + Parser P{Text}; + AssetCookManifest Manifest; + + const bool Parsed = P.ParseObject([&](const std::string &Key) -> bool { + if (Key == "entries") { + return P.ParseArray([&]() -> bool { + AssetCookManifestEntry Entry; + const bool EntryParsed = P.ParseObject([&](const std::string &EntryKey) -> bool { + if (EntryKey == "assetId") { + auto Value = P.ParseUint64(); + if (Value.has_value()) + Entry.Id = AssetId{*Value}; + return true; + } + if (EntryKey == "kind") { + auto Value = P.ParseString(); + if (Value.has_value()) + Entry.Kind = AssetKindFromString(*Value); + return true; + } + if (EntryKey == "relativePath") { + auto Value = P.ParseString(); + if (Value.has_value()) + Entry.RelativePath = *Value; + return true; + } + if (EntryKey == "cookedPath") { + auto Value = P.ParseString(); + if (Value.has_value()) + Entry.CookedPath = *Value; + return true; + } + if (EntryKey == "formatVersion") { + auto Value = P.ParseUint64(); + if (Value.has_value()) + Entry.FormatVersion = static_cast(*Value); + return true; + } + if (EntryKey == "sourceHash") { + auto Value = P.ParseUint64(); + if (Value.has_value()) + Entry.SourceHash = *Value; + return true; + } + return false; + }); + if (!EntryParsed) + return false; + Manifest.Entries.push_back(std::move(Entry)); + return true; + }); + } + return false; + }); + + if (!Parsed) { + A_CORE_WARN("AssetCookManifest: failed to parse '{}'", Path.string()); + return std::nullopt; + } + + return Manifest; +} + +bool SaveAssetCookManifest(const std::filesystem::path &Path, + const AssetCookManifest &Manifest) { + std::ofstream File(Path); + if (!File.is_open()) { + A_CORE_ERROR("AssetCookManifest: could not open '{}' for writing", + Path.string()); + return false; + } + + std::ostringstream Out; + Out << "{\n"; + Out << " \"entries\": [\n"; + for (size_t Index = 0; Index < Manifest.Entries.size(); ++Index) { + const auto &Entry = Manifest.Entries[Index]; + if (Index > 0) { + Out << ",\n"; + } + Out << " {\"assetId\":" << Entry.Id.Value + << ",\"kind\":" << EscapeJson(AssetKindToString(Entry.Kind)) + << ",\"relativePath\":" << EscapeJson(Entry.RelativePath) + << ",\"cookedPath\":" << EscapeJson(Entry.CookedPath) + << ",\"formatVersion\":" << Entry.FormatVersion + << ",\"sourceHash\":" << Entry.SourceHash << "}"; + } + Out << "\n ]\n"; + Out << "}\n"; + + File << Out.str(); + return File.good(); +} + +} // namespace Axiom::Assets diff --git a/Axiom/Assets/AssetCookManifest.h b/Axiom/Assets/AssetCookManifest.h new file mode 100644 index 00000000..22b66cb4 --- /dev/null +++ b/Axiom/Assets/AssetCookManifest.h @@ -0,0 +1,32 @@ +#pragma once + +#include "Assets/IAssetSource.h" + +#include +#include +#include +#include + +namespace Axiom::Assets { + +struct AssetCookManifestEntry { + AssetId Id; + AssetKind Kind{AssetKind::Unknown}; + std::string RelativePath; + std::string CookedPath; + uint32_t FormatVersion{0}; + uint64_t SourceHash{0}; +}; + +// Stores cooked-asset lookup metadata used by runtime asset sources. +struct AssetCookManifest { + std::vector Entries; +}; + +std::optional +LoadAssetCookManifest(const std::filesystem::path &Path); + +bool SaveAssetCookManifest(const std::filesystem::path &Path, + const AssetCookManifest &Manifest); + +} // namespace Axiom::Assets diff --git a/Axiom/Assets/AssetCooker.cpp b/Axiom/Assets/AssetCooker.cpp new file mode 100644 index 00000000..5489a97a --- /dev/null +++ b/Axiom/Assets/AssetCooker.cpp @@ -0,0 +1,246 @@ +#include "AssetCooker.h" + +#include "Assets/CookedMaterialAsset.h" +#include "Assets/CookedMeshAsset.h" +#include "Assets/CookedTextureAsset.h" +#include "Assets/IAssetSource.h" +#include "Assets/MeshAsset.h" +#include "Core/Log.h" + +#include +#include +#include +#include +#include +#include + +namespace Axiom::Assets { +namespace { + +uint64_t HashBytes(const std::vector &Bytes) { + return static_cast( + std::hash{}(std::string_view(Bytes.data(), Bytes.size()))); +} + +uint64_t HashString(std::string_view Text) { + return static_cast(std::hash{}(Text)); +} + +std::optional HashFileContents(const std::filesystem::path &Path) { + std::ifstream Stream(Path, std::ios::binary); + if (!Stream.is_open()) { + return std::nullopt; + } + + const std::vector Bytes((std::istreambuf_iterator(Stream)), + std::istreambuf_iterator()); + return HashBytes(Bytes); +} + +std::filesystem::path BuildCookedRelativePath( + const std::filesystem::path &RelativeAssetPath) { + std::filesystem::path Cooked = std::filesystem::path("Cooked") / RelativeAssetPath; + return Cooked; +} + +std::filesystem::path BuildCookedMeshRelativePath( + const std::filesystem::path &RelativeAssetPath) { + auto Cooked = BuildCookedRelativePath(RelativeAssetPath); + Cooked.replace_extension(".wmesh"); + return Cooked; +} + +std::filesystem::path BuildCookedTextureRelativePath( + const std::filesystem::path &RelativeAssetPath) { + auto Cooked = BuildCookedRelativePath(RelativeAssetPath); + Cooked.replace_extension(".wtex"); + return Cooked; +} + +std::filesystem::path BuildCookedMaterialRelativePath( + const std::filesystem::path &RelativeAssetPath) { + auto Cooked = BuildCookedRelativePath(RelativeAssetPath); + Cooked.replace_extension(".wmat"); + return Cooked; +} + +void UpsertManifestEntry(AssetCookManifest &Manifest, + AssetCookManifestEntry Entry) { + const auto It = std::find_if( + Manifest.Entries.begin(), Manifest.Entries.end(), + [&](const AssetCookManifestEntry &Existing) { + return Existing.Id.Value == Entry.Id.Value; + }); + if (It != Manifest.Entries.end()) { + *It = std::move(Entry); + return; + } + Manifest.Entries.push_back(std::move(Entry)); +} + +} // namespace + +std::optional +CookMeshAsset(const std::filesystem::path &ContentRoot, + const std::filesystem::path &RelativeAssetPath) { + const AssetId Asset = AssetIdFromRelativePath(RelativeAssetPath); + const std::filesystem::path SourcePath = ContentRoot / RelativeAssetPath; + const auto SourceHash = HashFileContents(SourcePath); + if (!SourceHash.has_value()) { + A_CORE_WARN("AssetCooker: failed to hash source asset '{}'", + SourcePath.string()); + return std::nullopt; + } + + const auto Scene = LoadBasicMeshAsset(SourcePath); + if (!Scene.has_value()) { + A_CORE_WARN("AssetCooker: failed to import source mesh '{}'", + SourcePath.string()); + return std::nullopt; + } + + const std::filesystem::path CookedRelativePath = + BuildCookedMeshRelativePath(RelativeAssetPath); + const std::filesystem::path CookedAbsolutePath = ContentRoot / CookedRelativePath; + std::error_code Ec; + std::filesystem::create_directories(CookedAbsolutePath.parent_path(), Ec); + if (Ec) { + A_CORE_WARN("AssetCooker: failed to create cooked asset directory '{}': {}", + CookedAbsolutePath.parent_path().string(), Ec.message()); + return std::nullopt; + } + + if (!SaveCookedMeshAsset(CookedAbsolutePath, ToCookedMeshSceneData(*Scene), + Asset)) { + return std::nullopt; + } + + const std::filesystem::path ManifestPath = + ContentRoot / "Cooked" / "AssetCookManifest.json"; + AssetCookManifest Manifest = + LoadAssetCookManifest(ManifestPath).value_or(AssetCookManifest{}); + + AssetCookManifestEntry Entry{ + .Id = Asset, + .Kind = AssetKind::Mesh, + .RelativePath = RelativeAssetPath.generic_string(), + .CookedPath = CookedRelativePath.generic_string(), + .FormatVersion = kCookedMeshFormatVersion, + .SourceHash = *SourceHash, + }; + UpsertManifestEntry(Manifest, Entry); + + if (!SaveAssetCookManifest(ManifestPath, Manifest)) { + return std::nullopt; + } + + return Entry; +} + +std::optional +CookTextureAsset(const std::filesystem::path &ContentRoot, + const std::filesystem::path &RelativeAssetPath) { + const AssetId Asset = AssetIdFromRelativePath(RelativeAssetPath); + const std::filesystem::path SourcePath = ContentRoot / RelativeAssetPath; + const auto SourceHash = HashFileContents(SourcePath); + if (!SourceHash.has_value()) { + A_CORE_WARN("AssetCooker: failed to hash source texture '{}'", + SourcePath.string()); + return std::nullopt; + } + + const auto Texture = LoadTextureFromFile(SourcePath); + if (!Texture || !Texture->IsValid()) { + A_CORE_WARN("AssetCooker: failed to decode source texture '{}'", + SourcePath.string()); + return std::nullopt; + } + + const std::filesystem::path CookedRelativePath = + BuildCookedTextureRelativePath(RelativeAssetPath); + const std::filesystem::path CookedAbsolutePath = ContentRoot / CookedRelativePath; + std::error_code Ec; + std::filesystem::create_directories(CookedAbsolutePath.parent_path(), Ec); + if (Ec) { + A_CORE_WARN("AssetCooker: failed to create cooked texture directory '{}': {}", + CookedAbsolutePath.parent_path().string(), Ec.message()); + return std::nullopt; + } + + if (!SaveCookedTextureAsset(CookedAbsolutePath, *Texture, Asset)) { + return std::nullopt; + } + + const std::filesystem::path ManifestPath = + ContentRoot / "Cooked" / "AssetCookManifest.json"; + AssetCookManifest Manifest = + LoadAssetCookManifest(ManifestPath).value_or(AssetCookManifest{}); + + AssetCookManifestEntry Entry{ + .Id = Asset, + .Kind = AssetKind::Texture, + .RelativePath = RelativeAssetPath.generic_string(), + .CookedPath = CookedRelativePath.generic_string(), + .FormatVersion = kCookedTextureFormatVersion, + .SourceHash = *SourceHash, + }; + UpsertManifestEntry(Manifest, Entry); + + if (!SaveAssetCookManifest(ManifestPath, Manifest)) { + return std::nullopt; + } + + return Entry; +} + +std::optional +CookMaterialAsset(const std::filesystem::path &ContentRoot, + const std::filesystem::path &RelativeMaterialPath, + const CookedMaterialData &Material) { + const AssetId Asset = AssetIdFromRelativePath(RelativeMaterialPath); + const std::filesystem::path CookedRelativePath = + BuildCookedMaterialRelativePath(RelativeMaterialPath); + const std::filesystem::path CookedAbsolutePath = ContentRoot / CookedRelativePath; + std::error_code Ec; + std::filesystem::create_directories(CookedAbsolutePath.parent_path(), Ec); + if (Ec) { + A_CORE_WARN("AssetCooker: failed to create cooked material directory '{}': {}", + CookedAbsolutePath.parent_path().string(), Ec.message()); + return std::nullopt; + } + + if (!SaveCookedMaterialAsset(CookedAbsolutePath, Material, Asset)) { + return std::nullopt; + } + + const std::filesystem::path ManifestPath = + ContentRoot / "Cooked" / "AssetCookManifest.json"; + AssetCookManifest Manifest = + LoadAssetCookManifest(ManifestPath).value_or(AssetCookManifest{}); + + const std::string HashInput = + std::to_string(Material.BaseColorFactor.r) + "|" + + std::to_string(Material.BaseColorFactor.g) + "|" + + std::to_string(Material.BaseColorFactor.b) + "|" + + std::to_string(Material.BaseColorFactor.a) + "|" + + std::to_string(Material.Metallic) + "|" + + std::to_string(Material.Roughness) + "|" + Material.TextureAssetPath; + + AssetCookManifestEntry Entry{ + .Id = Asset, + .Kind = AssetKind::Material, + .RelativePath = RelativeMaterialPath.generic_string(), + .CookedPath = CookedRelativePath.generic_string(), + .FormatVersion = kCookedMaterialFormatVersion, + .SourceHash = HashString(HashInput), + }; + UpsertManifestEntry(Manifest, Entry); + + if (!SaveAssetCookManifest(ManifestPath, Manifest)) { + return std::nullopt; + } + + return Entry; +} + +} // namespace Axiom::Assets diff --git a/Axiom/Assets/AssetCooker.h b/Axiom/Assets/AssetCooker.h new file mode 100644 index 00000000..c60c0da5 --- /dev/null +++ b/Axiom/Assets/AssetCooker.h @@ -0,0 +1,30 @@ +#pragma once + +#include "Assets/AssetCookManifest.h" +#include "Assets/CookedMaterialAsset.h" + +#include +#include + +namespace Axiom::Assets { + +// Imports a source mesh asset from the content directory, writes a cooked +// `.wmesh`, and updates the cook manifest entry for the asset. +std::optional +CookMeshAsset(const std::filesystem::path &ContentRoot, + const std::filesystem::path &RelativeAssetPath); + +// Decodes a source texture asset from the content directory, writes a cooked +// `.wtex`, and updates the cook manifest entry for the asset. +std::optional +CookTextureAsset(const std::filesystem::path &ContentRoot, + const std::filesystem::path &RelativeAssetPath); + +// Writes generated cooked material state under Content/Cooked and updates the +// cook manifest for the provided logical material path. +std::optional +CookMaterialAsset(const std::filesystem::path &ContentRoot, + const std::filesystem::path &RelativeMaterialPath, + const CookedMaterialData &Material); + +} // namespace Axiom::Assets diff --git a/Axiom/Assets/CookedAssetRuntime.cpp b/Axiom/Assets/CookedAssetRuntime.cpp new file mode 100644 index 00000000..3d50c21b --- /dev/null +++ b/Axiom/Assets/CookedAssetRuntime.cpp @@ -0,0 +1,123 @@ +#include "CookedAssetRuntime.h" + +#include "Assets/CookedMaterialAsset.h" +#include "Assets/CookedMeshAsset.h" +#include "Assets/CookedTextureAsset.h" +#include "Assets/IAssetSource.h" + +namespace Axiom::Assets { + +std::optional +FindContentRootForPath(const std::filesystem::path &Path) { + if (Path.empty()) { + return std::nullopt; + } + + std::filesystem::path Current = + std::filesystem::is_directory(Path) ? Path : Path.parent_path(); + while (!Current.empty()) { + if (Current.filename() == "Content") { + return Current; + } + const auto Parent = Current.parent_path(); + if (Parent == Current) { + break; + } + Current = Parent; + } + + return std::nullopt; +} + +std::optional +LoadCookedMeshAssetIfAvailable(const std::filesystem::path &Path) { + const auto ContentRoot = FindContentRootForPath(Path); + if (!ContentRoot.has_value()) { + return std::nullopt; + } + + std::error_code Ec; + const auto RelativePath = std::filesystem::relative(Path, *ContentRoot, Ec); + if (Ec) { + return std::nullopt; + } + + const CookedAssetSource CookedSource(*ContentRoot); + if (!CookedSource.HasManifest()) { + return std::nullopt; + } + + const auto CookedPath = + CookedSource.Resolve(AssetIdFromRelativePath(RelativePath)); + if (!CookedPath.has_value()) { + return std::nullopt; + } + + const auto CookedScene = LoadCookedMeshAsset(*CookedPath); + if (!CookedScene.has_value()) { + return std::nullopt; + } + + return ToRuntimeMeshSceneData(*CookedScene); +} + +TextureSourceDataRef +LoadCookedTextureAssetIfAvailable(const std::filesystem::path &Path) { + const auto ContentRoot = FindContentRootForPath(Path); + if (!ContentRoot.has_value()) { + return nullptr; + } + + std::error_code Ec; + const auto RelativePath = std::filesystem::relative(Path, *ContentRoot, Ec); + if (Ec) { + return nullptr; + } + + const CookedAssetSource CookedSource(*ContentRoot); + if (!CookedSource.HasManifest()) { + return nullptr; + } + + const auto CookedPath = + CookedSource.Resolve(AssetIdFromRelativePath(RelativePath)); + if (!CookedPath.has_value()) { + return nullptr; + } + + const auto CookedTexture = LoadCookedTextureAsset(*CookedPath); + if (!CookedTexture.has_value()) { + return nullptr; + } + + return std::make_shared(*CookedTexture); +} + +std::optional +LoadCookedMaterialAssetIfAvailable(const std::filesystem::path &Path) { + const auto ContentRoot = FindContentRootForPath(Path); + if (!ContentRoot.has_value()) { + return std::nullopt; + } + + std::error_code Ec; + const auto RelativePath = std::filesystem::relative(Path, *ContentRoot, Ec); + if (Ec) { + return std::nullopt; + } + + const CookedAssetSource CookedSource(*ContentRoot); + if (!CookedSource.HasManifest()) { + return std::nullopt; + } + + const auto CookedPath = + CookedSource.Resolve(AssetIdFromRelativePath(RelativePath)); + if (!CookedPath.has_value()) { + return std::nullopt; + } + + return LoadCookedMaterialAsset(*CookedPath); +} + +} // namespace Axiom::Assets diff --git a/Axiom/Assets/CookedAssetRuntime.h b/Axiom/Assets/CookedAssetRuntime.h new file mode 100644 index 00000000..0fd229f0 --- /dev/null +++ b/Axiom/Assets/CookedAssetRuntime.h @@ -0,0 +1,24 @@ +#pragma once + +#include "Assets/CookedMaterialAsset.h" +#include "Renderer/Material.h" +#include "Renderer/Mesh.h" + +#include +#include + +namespace Axiom::Assets { + +std::optional +FindContentRootForPath(const std::filesystem::path &Path); + +std::optional +LoadCookedMeshAssetIfAvailable(const std::filesystem::path &Path); + +TextureSourceDataRef +LoadCookedTextureAssetIfAvailable(const std::filesystem::path &Path); + +std::optional +LoadCookedMaterialAssetIfAvailable(const std::filesystem::path &Path); + +} // namespace Axiom::Assets diff --git a/Axiom/Assets/CookedMaterialAsset.cpp b/Axiom/Assets/CookedMaterialAsset.cpp new file mode 100644 index 00000000..3b9738b5 --- /dev/null +++ b/Axiom/Assets/CookedMaterialAsset.cpp @@ -0,0 +1,119 @@ +#include "CookedMaterialAsset.h" + +#include "Core/Log.h" + +#include +#include +#include +#include + +namespace Axiom::Assets { +namespace { + +constexpr std::array kMagic = {'W', 'M', 'A', 'T'}; + +struct FileHeader { + char Magic[4]; + uint32_t Version; + uint64_t AssetIdValue; + std::array BaseColorFactor; + float Metallic; + float Roughness; + uint32_t TexturePathLength; +}; + +static_assert(std::is_trivially_copyable_v); + +template +bool WriteValue(std::ofstream &Stream, const T &Value) { + Stream.write(reinterpret_cast(&Value), sizeof(T)); + return Stream.good(); +} + +template bool ReadValue(std::ifstream &Stream, T &Value) { + Stream.read(reinterpret_cast(&Value), sizeof(T)); + return Stream.good(); +} + +} // namespace + +bool SaveCookedMaterialAsset(const std::filesystem::path &Path, + const CookedMaterialData &Material, + AssetId Asset) { + std::ofstream Stream(Path, std::ios::binary); + if (!Stream.is_open()) { + A_CORE_ERROR("CookedMaterialAsset: could not open '{}' for writing", + Path.string()); + return false; + } + + const FileHeader Header{ + .Magic = {kMagic[0], kMagic[1], kMagic[2], kMagic[3]}, + .Version = kCookedMaterialFormatVersion, + .AssetIdValue = Asset.Value, + .BaseColorFactor = {Material.BaseColorFactor.r, Material.BaseColorFactor.g, + Material.BaseColorFactor.b, Material.BaseColorFactor.a}, + .Metallic = Material.Metallic, + .Roughness = Material.Roughness, + .TexturePathLength = static_cast(Material.TextureAssetPath.size()), + }; + if (!WriteValue(Stream, Header)) { + return false; + } + + if (!Material.TextureAssetPath.empty()) { + Stream.write(Material.TextureAssetPath.data(), + static_cast(Material.TextureAssetPath.size())); + } + + return Stream.good(); +} + +std::optional +LoadCookedMaterialAsset(const std::filesystem::path &Path) { + std::ifstream Stream(Path, std::ios::binary); + if (!Stream.is_open()) { + return std::nullopt; + } + + FileHeader Header{}; + if (!ReadValue(Stream, Header)) { + A_CORE_WARN("CookedMaterialAsset: failed to read header from '{}'", + Path.string()); + return std::nullopt; + } + + if (Header.Magic[0] != kMagic[0] || Header.Magic[1] != kMagic[1] || + Header.Magic[2] != kMagic[2] || Header.Magic[3] != kMagic[3]) { + A_CORE_WARN("CookedMaterialAsset: invalid magic in '{}'", Path.string()); + return std::nullopt; + } + + if (Header.Version != kCookedMaterialFormatVersion) { + A_CORE_WARN("CookedMaterialAsset: unsupported version {} in '{}'", + Header.Version, Path.string()); + return std::nullopt; + } + + CookedMaterialData Material{ + .BaseColorFactor = glm::vec4(Header.BaseColorFactor[0], + Header.BaseColorFactor[1], + Header.BaseColorFactor[2], + Header.BaseColorFactor[3]), + .Metallic = Header.Metallic, + .Roughness = Header.Roughness, + }; + + if (Header.TexturePathLength > 0) { + Material.TextureAssetPath.resize(Header.TexturePathLength); + Stream.read(Material.TextureAssetPath.data(), + static_cast(Material.TextureAssetPath.size())); + if (!Stream.good()) { + return std::nullopt; + } + } + + return Material; +} + +} // namespace Axiom::Assets diff --git a/Axiom/Assets/CookedMaterialAsset.h b/Axiom/Assets/CookedMaterialAsset.h new file mode 100644 index 00000000..e55a78f0 --- /dev/null +++ b/Axiom/Assets/CookedMaterialAsset.h @@ -0,0 +1,28 @@ +#pragma once + +#include "Session/SessionTypes.h" + +#include +#include +#include +#include + +namespace Axiom::Assets { + +constexpr uint32_t kCookedMaterialFormatVersion = 1; + +struct CookedMaterialData { + glm::vec4 BaseColorFactor{1.0f}; + float Metallic{0.0f}; + float Roughness{0.5f}; + std::string TextureAssetPath; +}; + +bool SaveCookedMaterialAsset(const std::filesystem::path &Path, + const CookedMaterialData &Material, + AssetId Asset); + +std::optional +LoadCookedMaterialAsset(const std::filesystem::path &Path); + +} // namespace Axiom::Assets diff --git a/Axiom/Assets/CookedMeshAsset.cpp b/Axiom/Assets/CookedMeshAsset.cpp new file mode 100644 index 00000000..05b29b1a --- /dev/null +++ b/Axiom/Assets/CookedMeshAsset.cpp @@ -0,0 +1,233 @@ +#include "CookedMeshAsset.h" + +#include "Core/Log.h" + +#include +#include +#include +#include +#include + +namespace Axiom::Assets { +namespace { + +constexpr std::array kMagic = {'W', 'M', 'S', 'H'}; + +struct FileHeader { + char Magic[4]; + uint32_t Version; + uint64_t AssetIdValue; + uint32_t InstanceCount; +}; + +struct InstanceHeader { + uint32_t NameLength; + uint32_t VertexCount; + uint32_t IndexCount; + std::array BoundsMin; + std::array BoundsMax; + std::array Transform; +}; + +static_assert(std::is_trivially_copyable_v); +static_assert(std::is_trivially_copyable_v); + +template +bool WriteValue(std::ofstream &Stream, const T &Value) { + Stream.write(reinterpret_cast(&Value), sizeof(T)); + return Stream.good(); +} + +template bool ReadValue(std::ifstream &Stream, T &Value) { + Stream.read(reinterpret_cast(&Value), sizeof(T)); + return Stream.good(); +} + +std::array FlattenMatrix(const glm::mat4 &Matrix) { + std::array Values{}; + for (int Column = 0; Column < 4; ++Column) { + for (int Row = 0; Row < 4; ++Row) { + Values[Column * 4 + Row] = Matrix[Column][Row]; + } + } + return Values; +} + +glm::mat4 ExpandMatrix(const std::array &Values) { + glm::mat4 Matrix(1.0f); + for (int Column = 0; Column < 4; ++Column) { + for (int Row = 0; Row < 4; ++Row) { + Matrix[Column][Row] = Values[Column * 4 + Row]; + } + } + return Matrix; +} + +} // namespace + +bool SaveCookedMeshAsset(const std::filesystem::path &Path, + const CookedMeshSceneData &Scene, + AssetId Asset) { + std::ofstream Stream(Path, std::ios::binary); + if (!Stream.is_open()) { + A_CORE_ERROR("CookedMeshAsset: could not open '{}' for writing", + Path.string()); + return false; + } + + const FileHeader Header{ + .Magic = {kMagic[0], kMagic[1], kMagic[2], kMagic[3]}, + .Version = kCookedMeshFormatVersion, + .AssetIdValue = Asset.Value, + .InstanceCount = static_cast(Scene.Instances.size()), + }; + if (!WriteValue(Stream, Header)) + return false; + + for (const auto &Instance : Scene.Instances) { + const InstanceHeader InstanceMeta{ + .NameLength = static_cast(Instance.Name.size()), + .VertexCount = static_cast(Instance.Mesh.Vertices.size()), + .IndexCount = static_cast(Instance.Mesh.Indices.size()), + .BoundsMin = {Instance.Mesh.BoundsMin.x, Instance.Mesh.BoundsMin.y, + Instance.Mesh.BoundsMin.z}, + .BoundsMax = {Instance.Mesh.BoundsMax.x, Instance.Mesh.BoundsMax.y, + Instance.Mesh.BoundsMax.z}, + .Transform = FlattenMatrix(Instance.Transform), + }; + if (!WriteValue(Stream, InstanceMeta)) + return false; + + if (!Instance.Name.empty()) { + Stream.write(Instance.Name.data(), + static_cast(Instance.Name.size())); + if (!Stream.good()) + return false; + } + + if (!Instance.Mesh.Vertices.empty()) { + Stream.write( + reinterpret_cast(Instance.Mesh.Vertices.data()), + static_cast(Instance.Mesh.Vertices.size() * + sizeof(MeshVertex))); + if (!Stream.good()) + return false; + } + + if (!Instance.Mesh.Indices.empty()) { + Stream.write(reinterpret_cast(Instance.Mesh.Indices.data()), + static_cast(Instance.Mesh.Indices.size() * + sizeof(uint32_t))); + if (!Stream.good()) + return false; + } + } + + return Stream.good(); +} + +std::optional +LoadCookedMeshAsset(const std::filesystem::path &Path) { + std::ifstream Stream(Path, std::ios::binary); + if (!Stream.is_open()) { + return std::nullopt; + } + + FileHeader Header{}; + if (!ReadValue(Stream, Header)) { + A_CORE_WARN("CookedMeshAsset: failed to read header from '{}'", + Path.string()); + return std::nullopt; + } + + if (!std::equal(Header.Magic, Header.Magic + 4, kMagic.begin())) { + A_CORE_WARN("CookedMeshAsset: invalid magic in '{}'", Path.string()); + return std::nullopt; + } + + if (Header.Version != kCookedMeshFormatVersion) { + A_CORE_WARN("CookedMeshAsset: unsupported version {} in '{}'", + Header.Version, Path.string()); + return std::nullopt; + } + + CookedMeshSceneData Scene; + Scene.Instances.reserve(Header.InstanceCount); + + for (uint32_t InstanceIndex = 0; InstanceIndex < Header.InstanceCount; + ++InstanceIndex) { + InstanceHeader InstanceMeta{}; + if (!ReadValue(Stream, InstanceMeta)) { + A_CORE_WARN("CookedMeshAsset: failed to read instance header from '{}'", + Path.string()); + return std::nullopt; + } + + CookedMeshSceneData::InstanceData Instance; + Instance.Name.resize(InstanceMeta.NameLength); + if (InstanceMeta.NameLength > 0) { + Stream.read(Instance.Name.data(), + static_cast(Instance.Name.size())); + if (!Stream.good()) + return std::nullopt; + } + + Instance.Mesh.Vertices.resize(InstanceMeta.VertexCount); + if (InstanceMeta.VertexCount > 0) { + Stream.read(reinterpret_cast(Instance.Mesh.Vertices.data()), + static_cast(Instance.Mesh.Vertices.size() * + sizeof(MeshVertex))); + if (!Stream.good()) + return std::nullopt; + } + + Instance.Mesh.Indices.resize(InstanceMeta.IndexCount); + if (InstanceMeta.IndexCount > 0) { + Stream.read(reinterpret_cast(Instance.Mesh.Indices.data()), + static_cast(Instance.Mesh.Indices.size() * + sizeof(uint32_t))); + if (!Stream.good()) + return std::nullopt; + } + + Instance.Mesh.BoundsMin = glm::vec3(InstanceMeta.BoundsMin[0], + InstanceMeta.BoundsMin[1], + InstanceMeta.BoundsMin[2]); + Instance.Mesh.BoundsMax = glm::vec3(InstanceMeta.BoundsMax[0], + InstanceMeta.BoundsMax[1], + InstanceMeta.BoundsMax[2]); + Instance.Transform = ExpandMatrix(InstanceMeta.Transform); + Scene.Instances.push_back(std::move(Instance)); + } + + return Scene; +} + +CookedMeshSceneData ToCookedMeshSceneData(const MeshSceneData &Scene) { + CookedMeshSceneData Out; + Out.Instances.reserve(Scene.Instances.size()); + for (const auto &Instance : Scene.Instances) { + Out.Instances.push_back({ + .Name = Instance.Name, + .Mesh = Instance.Mesh, + .Transform = Instance.Transform, + }); + } + return Out; +} + +MeshSceneData ToRuntimeMeshSceneData(const CookedMeshSceneData &Scene) { + MeshSceneData Out; + Out.Instances.reserve(Scene.Instances.size()); + for (const auto &Instance : Scene.Instances) { + Out.Instances.push_back({ + .Name = Instance.Name, + .Mesh = Instance.Mesh, + .Material = std::make_shared(), + .Transform = Instance.Transform, + }); + } + return Out; +} + +} // namespace Axiom::Assets diff --git a/Axiom/Assets/CookedMeshAsset.h b/Axiom/Assets/CookedMeshAsset.h new file mode 100644 index 00000000..c11d6740 --- /dev/null +++ b/Axiom/Assets/CookedMeshAsset.h @@ -0,0 +1,35 @@ +#pragma once + +#include "Renderer/Mesh.h" +#include "Session/SessionTypes.h" + +#include +#include +#include +#include + +namespace Axiom::Assets { + +constexpr uint32_t kCookedMeshFormatVersion = 1; + +struct CookedMeshSceneData { + struct InstanceData { + std::string Name; + MeshData Mesh; + glm::mat4 Transform{1.0f}; + }; + + std::vector Instances; +}; + +bool SaveCookedMeshAsset(const std::filesystem::path &Path, + const CookedMeshSceneData &Scene, + AssetId Asset); + +std::optional +LoadCookedMeshAsset(const std::filesystem::path &Path); + +CookedMeshSceneData ToCookedMeshSceneData(const MeshSceneData &Scene); +MeshSceneData ToRuntimeMeshSceneData(const CookedMeshSceneData &Scene); + +} // namespace Axiom::Assets diff --git a/Axiom/Assets/CookedTextureAsset.cpp b/Axiom/Assets/CookedTextureAsset.cpp new file mode 100644 index 00000000..ff3b5e6a --- /dev/null +++ b/Axiom/Assets/CookedTextureAsset.cpp @@ -0,0 +1,123 @@ +#include "CookedTextureAsset.h" + +#include "Core/Log.h" + +#include +#include +#include +#include + +namespace Axiom::Assets { +namespace { + +constexpr std::array kMagic = {'W', 'T', 'E', 'X'}; + +struct FileHeader { + char Magic[4]; + uint32_t Version; + uint64_t AssetIdValue; + uint32_t Width; + uint32_t Height; + uint32_t ChannelCount; + uint32_t PixelByteCount; +}; + +static_assert(std::is_trivially_copyable_v); + +template +bool WriteValue(std::ofstream &Stream, const T &Value) { + Stream.write(reinterpret_cast(&Value), sizeof(T)); + return Stream.good(); +} + +template bool ReadValue(std::ifstream &Stream, T &Value) { + Stream.read(reinterpret_cast(&Value), sizeof(T)); + return Stream.good(); +} + +} // namespace + +bool SaveCookedTextureAsset(const std::filesystem::path &Path, + const TextureSourceData &Texture, AssetId Asset) { + if (!Texture.IsValid()) { + A_CORE_WARN("CookedTextureAsset: refusing to save invalid texture '{}'", + Path.string()); + return false; + } + + std::ofstream Stream(Path, std::ios::binary); + if (!Stream.is_open()) { + A_CORE_ERROR("CookedTextureAsset: could not open '{}' for writing", + Path.string()); + return false; + } + + const FileHeader Header{ + .Magic = {kMagic[0], kMagic[1], kMagic[2], kMagic[3]}, + .Version = kCookedTextureFormatVersion, + .AssetIdValue = Asset.Value, + .Width = Texture.Width, + .Height = Texture.Height, + .ChannelCount = 4, + .PixelByteCount = static_cast(Texture.Pixels.size()), + }; + if (!WriteValue(Stream, Header)) + return false; + + Stream.write(reinterpret_cast(Texture.Pixels.data()), + static_cast(Texture.Pixels.size())); + return Stream.good(); +} + +std::optional +LoadCookedTextureAsset(const std::filesystem::path &Path) { + std::ifstream Stream(Path, std::ios::binary); + if (!Stream.is_open()) { + return std::nullopt; + } + + FileHeader Header{}; + if (!ReadValue(Stream, Header)) { + A_CORE_WARN("CookedTextureAsset: failed to read header from '{}'", + Path.string()); + return std::nullopt; + } + + if (Header.Magic[0] != kMagic[0] || Header.Magic[1] != kMagic[1] || + Header.Magic[2] != kMagic[2] || Header.Magic[3] != kMagic[3]) { + A_CORE_WARN("CookedTextureAsset: invalid magic in '{}'", Path.string()); + return std::nullopt; + } + + if (Header.Version != kCookedTextureFormatVersion) { + A_CORE_WARN("CookedTextureAsset: unsupported version {} in '{}'", + Header.Version, Path.string()); + return std::nullopt; + } + + if (Header.ChannelCount != 4) { + A_CORE_WARN("CookedTextureAsset: unsupported channel count {} in '{}'", + Header.ChannelCount, Path.string()); + return std::nullopt; + } + + TextureSourceData Texture; + Texture.Width = Header.Width; + Texture.Height = Header.Height; + Texture.Pixels.resize(Header.PixelByteCount); + Stream.read(reinterpret_cast(Texture.Pixels.data()), + static_cast(Texture.Pixels.size())); + if (!Stream.good()) { + return std::nullopt; + } + + if (!Texture.IsValid()) { + A_CORE_WARN("CookedTextureAsset: decoded invalid texture payload from '{}'", + Path.string()); + return std::nullopt; + } + + return Texture; +} + +} // namespace Axiom::Assets diff --git a/Axiom/Assets/CookedTextureAsset.h b/Axiom/Assets/CookedTextureAsset.h new file mode 100644 index 00000000..abe8fb67 --- /dev/null +++ b/Axiom/Assets/CookedTextureAsset.h @@ -0,0 +1,19 @@ +#pragma once + +#include "Renderer/Material.h" +#include "Session/SessionTypes.h" + +#include +#include + +namespace Axiom::Assets { + +constexpr uint32_t kCookedTextureFormatVersion = 1; + +bool SaveCookedTextureAsset(const std::filesystem::path &Path, + const TextureSourceData &Texture, AssetId Asset); + +std::optional +LoadCookedTextureAsset(const std::filesystem::path &Path); + +} // namespace Axiom::Assets diff --git a/Axiom/Assets/IAssetSource.cpp b/Axiom/Assets/IAssetSource.cpp index 6f71dae6..44820089 100644 --- a/Axiom/Assets/IAssetSource.cpp +++ b/Axiom/Assets/IAssetSource.cpp @@ -1,4 +1,7 @@ #include "IAssetSource.h" +#include "AssetCookManifest.h" + +#include #include namespace Axiom::Assets { @@ -8,11 +11,15 @@ AssetKind KindFromExtension(const std::filesystem::path &Path) { auto ext = Path.extension().string(); if (ext == ".glb" || ext == ".gltf" || ext == ".fbx" || ext == ".obj") return AssetKind::Mesh; - return AssetKind::Texture; -} - -AssetId IdFromRelPath(const std::filesystem::path &RelPath) { - return AssetId{std::hash{}(RelPath.string())}; + if (ext == ".png" || ext == ".jpg" || ext == ".jpeg") + return AssetKind::Texture; + if (ext == ".wmesh") + return AssetKind::Mesh; + if (ext == ".wtex") + return AssetKind::Texture; + if (ext == ".wmat") + return AssetKind::Material; + return AssetKind::Unknown; } constexpr std::string_view kContentExtensions[] = {".glb", ".gltf", ".fbx", @@ -29,6 +36,10 @@ bool IsContentFile(const std::filesystem::path &Path) { } // namespace +AssetId AssetIdFromRelativePath(const std::filesystem::path &RelPath) { + return AssetId{std::hash{}(RelPath.generic_string())}; +} + LocalAssetSource::LocalAssetSource(std::filesystem::path Root) : m_Root(std::move(Root)) { if (!std::filesystem::exists(m_Root)) @@ -41,10 +52,10 @@ LocalAssetSource::LocalAssetSource(std::filesystem::path Root) auto rel = std::filesystem::relative(Entry.path(), m_Root); AssetDescriptor desc; - desc.Id = IdFromRelPath(rel); + desc.Id = AssetIdFromRelativePath(rel); desc.Name = rel.stem().string(); desc.Kind = KindFromExtension(rel); - desc.RelativePath = rel.string(); + desc.RelativePath = rel.generic_string(); m_Index.push_back(std::move(desc)); } } @@ -64,4 +75,37 @@ LocalAssetSource::ResolveRelative(std::string_view RelPath) const { return m_Root / RelPath; } +CookedAssetSource::CookedAssetSource(std::filesystem::path ContentRoot) + : m_ContentRoot(std::move(ContentRoot)) { + const auto ManifestPath = m_ContentRoot / "Cooked" / "AssetCookManifest.json"; + const auto Manifest = LoadAssetCookManifest(ManifestPath); + if (!Manifest.has_value()) + return; + + m_HasManifest = true; + m_Index.reserve(Manifest->Entries.size()); + for (const auto &Entry : Manifest->Entries) { + AssetDescriptor Desc; + Desc.Id = Entry.Id; + Desc.Kind = Entry.Kind; + Desc.RelativePath = Entry.CookedPath; + Desc.Name = std::filesystem::path(Entry.RelativePath).stem().string(); + m_Index.push_back(std::move(Desc)); + } +} + +std::optional +CookedAssetSource::Resolve(AssetId Id) const { + const auto It = std::find_if( + m_Index.begin(), m_Index.end(), + [&](const AssetDescriptor &Desc) { return Desc.Id.Value == Id.Value; }); + if (It == m_Index.end()) + return std::nullopt; + return m_ContentRoot / It->RelativePath; +} + +std::vector CookedAssetSource::List() const { return m_Index; } + +bool CookedAssetSource::HasManifest() const { return m_HasManifest; } + } // namespace Axiom::Assets diff --git a/Axiom/Assets/IAssetSource.h b/Axiom/Assets/IAssetSource.h index 5164b473..f08f3de6 100644 --- a/Axiom/Assets/IAssetSource.h +++ b/Axiom/Assets/IAssetSource.h @@ -4,11 +4,12 @@ #include #include #include +#include #include namespace Axiom::Assets { -enum class AssetKind { Mesh, Texture }; +enum class AssetKind { Mesh, Texture, Material, Unknown }; struct AssetDescriptor { AssetId Id; @@ -24,6 +25,8 @@ class IAssetSource { virtual std::vector List() const = 0; }; +AssetId AssetIdFromRelativePath(const std::filesystem::path &RelPath); + // Scans a root directory on disk and maps discovered content files to stable // AssetIds derived from their relative paths. class LocalAssetSource : public IAssetSource { @@ -41,4 +44,19 @@ class LocalAssetSource : public IAssetSource { std::vector m_Index; }; +// Resolves cooked asset binaries from the cook manifest inside Content/Cooked. +class CookedAssetSource : public IAssetSource { +public: + explicit CookedAssetSource(std::filesystem::path ContentRoot); + + std::optional Resolve(AssetId Id) const override; + std::vector List() const override; + bool HasManifest() const; + +private: + std::filesystem::path m_ContentRoot; + bool m_HasManifest{false}; + std::vector m_Index; +}; + } // namespace Axiom::Assets diff --git a/Axiom/Assets/MeshAsset.cpp b/Axiom/Assets/MeshAsset.cpp index 860fdd0e..cdefd488 100644 --- a/Axiom/Assets/MeshAsset.cpp +++ b/Axiom/Assets/MeshAsset.cpp @@ -1,5 +1,8 @@ #include "Assets/MeshAsset.h" #include "Assets/AssimpImporter.h" +#include "Assets/CookedAssetRuntime.h" +#include "Assets/CookedMeshAsset.h" +#include "Assets/CookedTextureAsset.h" #include "Core/Log.h" @@ -373,9 +376,11 @@ void AppendNodeMeshes(const fastgltf::Asset &Asset, size_t NodeIndex, AppendNodeMeshes(Asset, ChildIndex, WorldTransform, SceneData, ResolveMaterial); } } + } // namespace -std::optional LoadBasicMeshAsset(const std::filesystem::path &Path) { +std::optional +LoadBasicMeshAssetFromSource(const std::filesystem::path &Path) { const std::string Ext = ToLowerCopy(Path.extension().string()); if (Ext == ".fbx" || Ext == ".obj") { AssimpImporter Importer; @@ -512,10 +517,46 @@ std::optional LoadBasicMeshAsset(const std::filesystem::path &Pat return SceneData; } -TextureSourceDataRef LoadTextureFromFile(const std::filesystem::path &Path) { +std::optional LoadBasicMeshAsset(const std::filesystem::path &Path) { + const std::string Ext = ToLowerCopy(Path.extension().string()); + if (Ext == ".wmesh") { + const auto CookedScene = LoadCookedMeshAsset(Path); + if (!CookedScene.has_value()) { + return std::nullopt; + } + return ToRuntimeMeshSceneData(*CookedScene); + } + + if (auto CookedScene = LoadCookedMeshAssetIfAvailable(Path); + CookedScene.has_value()) { + return CookedScene; + } + + return LoadBasicMeshAssetFromSource(Path); +} + +TextureSourceDataRef LoadTextureFromSourceFile(const std::filesystem::path &Path) { return DecodeTextureFromFile(Path); } +TextureSourceDataRef LoadTextureFromFile(const std::filesystem::path &Path) { + const std::string Ext = ToLowerCopy(Path.extension().string()); + if (Ext == ".wtex") { + const auto CookedTexture = LoadCookedTextureAsset(Path); + if (!CookedTexture.has_value()) { + return nullptr; + } + return std::make_shared(*CookedTexture); + } + + if (auto CookedTexture = LoadCookedTextureAssetIfAvailable(Path); + CookedTexture != nullptr) { + return CookedTexture; + } + + return LoadTextureFromSourceFile(Path); +} + TextureSourceDataRef LoadTextureFromMemory(const unsigned char *Bytes, int Length, const std::string &DebugName) { diff --git a/Axiom/Assets/MeshAsset.h b/Axiom/Assets/MeshAsset.h index d4c893d6..7ba40b84 100644 --- a/Axiom/Assets/MeshAsset.h +++ b/Axiom/Assets/MeshAsset.h @@ -8,7 +8,10 @@ namespace Axiom::Assets { std::optional LoadBasicMeshAsset(const std::filesystem::path &Path); +std::optional +LoadBasicMeshAssetFromSource(const std::filesystem::path &Path); TextureSourceDataRef LoadTextureFromFile(const std::filesystem::path &Path); +TextureSourceDataRef LoadTextureFromSourceFile(const std::filesystem::path &Path); TextureSourceDataRef LoadTextureFromMemory(const unsigned char *Bytes, int Length, const std::string &DebugName); } // namespace Axiom::Assets diff --git a/Axiom/Assets/SceneFile.cpp b/Axiom/Assets/SceneFile.cpp index bc2582e5..3a0697bc 100644 --- a/Axiom/Assets/SceneFile.cpp +++ b/Axiom/Assets/SceneFile.cpp @@ -1,4 +1,6 @@ #include "Assets/SceneFile.h" +#include "Assets/AssetCooker.h" +#include "Assets/CookedAssetRuntime.h" #include "Assets/MeshAsset.h" #include "Core/Log.h" @@ -44,6 +46,16 @@ std::string SerializeVec3(const glm::vec3 &V) { return S.str(); } +std::filesystem::path ResolveContentRootForScenePath( + const std::filesystem::path &ScenePath) { + if (const auto ContentRoot = FindContentRootForPath(ScenePath); + ContentRoot.has_value()) { + return *ContentRoot; + } + + return std::filesystem::path(AXIOM_CONTENT_DIR); +} + const char *KindStr(EditorSceneItemKind K) { switch (K) { case EditorSceneItemKind::Folder: return "Folder"; @@ -78,6 +90,8 @@ void SerializeSceneItemsFlat( bool SaveSceneToFile(const std::filesystem::path &Path, const EditorSceneState &Scene) { + const std::filesystem::path ContentRoot = ResolveContentRootForScenePath(Path); + // Build per-object asset path lookup from MeshInstances. std::unordered_map AssetPathByObjectId; for (const auto &Inst : Scene.MeshInstances) { @@ -122,6 +136,21 @@ bool SaveSceneToFile(const std::filesystem::path &Path, if (AssetIt != AssetPathByObjectId.end()) { Out << ",\"assetRelativePath\":" << EscStr(AssetIt->second); } + if (Details.Material.has_value()) { + const std::filesystem::path MaterialPath = + std::filesystem::path("Generated/Materials") / Id; + const auto MaterialCooked = CookMaterialAsset( + ContentRoot, MaterialPath, + {.BaseColorFactor = Details.Material->BaseColorFactor, + .Metallic = Details.Material->Metallic, + .Roughness = Details.Material->Roughness, + .TextureAssetPath = + Details.Material->TextureAssetPath.value_or("")}); + if (MaterialCooked.has_value()) { + Out << ",\"materialAssetPath\":" + << EscStr(MaterialCooked->RelativePath); + } + } if (Details.Material.has_value() && Details.Material->TextureAssetPath.has_value()) { Out << ",\"textureAssetPath\":" << EscStr(*Details.Material->TextureAssetPath); } @@ -328,6 +357,8 @@ EditorSceneItemKind KindFromStr(std::string_view S) { std::optional LoadSceneFromFile(const std::filesystem::path &Path) { + const std::filesystem::path ContentRoot = ResolveContentRootForScenePath(Path); + std::ifstream File(Path); if (!File.is_open()) return std::nullopt; const std::string Text((std::istreambuf_iterator(File)), @@ -350,6 +381,7 @@ LoadSceneFromFile(const std::filesystem::path &Path) { std::optional Transform; std::optional ScriptClass; std::string AssetRelativePath; + std::string MaterialAssetPath; std::string TextureAssetPath; std::optional Light; }; @@ -422,6 +454,9 @@ LoadSceneFromFile(const std::filesystem::path &Path) { if (K == "assetRelativePath") { auto V = P.ParseString(); if (V) Data.AssetRelativePath = *V; return true; } + if (K == "materialAssetPath") { + auto V = P.ParseString(); if (V) Data.MaterialAssetPath = *V; return true; + } if (K == "textureAssetPath") { P.SkipWs(); if (P.Peek() == 'n') { P.ParseNull(); } else { auto V = P.ParseString(); if (V) Data.TextureAssetPath = *V; } @@ -522,8 +557,8 @@ LoadSceneFromFile(const std::filesystem::path &Path) { std::unordered_set LoadedByAssetPath; for (const auto &[ObjId, Data] : Objects) { if (Data.Kind != EditorSceneItemKind::Mesh || Data.AssetRelativePath.empty()) continue; - const auto FullPath = - std::filesystem::path(AXIOM_CONTENT_DIR) / Data.AssetRelativePath; + CookMeshAsset(ContentRoot, Data.AssetRelativePath); + const auto FullPath = ContentRoot / Data.AssetRelativePath; const auto SceneData = LoadBasicMeshAsset(FullPath); if (!SceneData.has_value() || SceneData->Instances.empty()) { A_CORE_WARN("SceneFile: failed to load asset '{}' for object '{}'", @@ -544,9 +579,29 @@ LoadSceneFromFile(const std::filesystem::path &Path) { Transform = M; } auto Material = SceneData->Instances[0].Material; + if (!Data.MaterialAssetPath.empty()) { + const auto CookedMaterial = + LoadCookedMaterialAssetIfAvailable(ContentRoot / Data.MaterialAssetPath); + if (CookedMaterial.has_value()) { + if (!Material) { + Material = std::make_shared(); + } + Material->BaseColorFactor = CookedMaterial->BaseColorFactor; + Material->Metallic = CookedMaterial->Metallic; + Material->Roughness = CookedMaterial->Roughness; + if (!CookedMaterial->TextureAssetPath.empty()) { + const auto TexPath = ContentRoot / CookedMaterial->TextureAssetPath; + auto Tex = LoadTextureFromFile(TexPath); + if (Tex) { + Material->BaseColorTexture = std::move(Tex); + Material->TextureAssetPath = CookedMaterial->TextureAssetPath; + } + } + } + } if (!Data.TextureAssetPath.empty()) { - const auto TexPath = - std::filesystem::path(AXIOM_CONTENT_DIR) / Data.TextureAssetPath; + CookTextureAsset(ContentRoot, Data.TextureAssetPath); + const auto TexPath = ContentRoot / Data.TextureAssetPath; auto Tex = LoadTextureFromFile(TexPath); if (Tex) { if (!Material) Material = std::make_shared(); @@ -565,9 +620,28 @@ LoadSceneFromFile(const std::filesystem::path &Path) { // Propagate textureAssetPath into ObjectDetails so inspector shows it. { const auto DetailsIt = State.ObjectDetailsById.find(ObjId); - if (DetailsIt != State.ObjectDetailsById.end() && !Data.TextureAssetPath.empty()) { - if (!DetailsIt->second.Material) DetailsIt->second.Material = EditorMaterialProperties{}; - DetailsIt->second.Material->TextureAssetPath = Data.TextureAssetPath; + if (DetailsIt != State.ObjectDetailsById.end()) { + if (!DetailsIt->second.Material) { + DetailsIt->second.Material = EditorMaterialProperties{}; + } + if (!Data.MaterialAssetPath.empty()) { + const auto CookedMaterial = + LoadCookedMaterialAssetIfAvailable(ContentRoot / + Data.MaterialAssetPath); + if (CookedMaterial.has_value()) { + DetailsIt->second.Material->BaseColorFactor = + CookedMaterial->BaseColorFactor; + DetailsIt->second.Material->Metallic = CookedMaterial->Metallic; + DetailsIt->second.Material->Roughness = CookedMaterial->Roughness; + if (!CookedMaterial->TextureAssetPath.empty()) { + DetailsIt->second.Material->TextureAssetPath = + CookedMaterial->TextureAssetPath; + } + } + } + if (!Data.TextureAssetPath.empty()) { + DetailsIt->second.Material->TextureAssetPath = Data.TextureAssetPath; + } } } LoadedByAssetPath.insert(ObjId); @@ -575,8 +649,8 @@ LoadSceneFromFile(const std::filesystem::path &Path) { // --- Stage 4b: reload remaining mesh instances from the global mesh asset --- if (!MeshAsset.empty() && !MeshNameToObjectId.empty()) { - const auto MeshPath = - std::filesystem::path(AXIOM_CONTENT_DIR) / MeshAsset; + CookMeshAsset(ContentRoot, MeshAsset); + const auto MeshPath = ContentRoot / MeshAsset; const auto SceneData = LoadBasicMeshAsset(MeshPath); if (SceneData.has_value()) { for (const auto &Instance : SceneData->Instances) { diff --git a/Axiom/CMakeLists.txt b/Axiom/CMakeLists.txt index c906ba58..5b183273 100644 --- a/Axiom/CMakeLists.txt +++ b/Axiom/CMakeLists.txt @@ -1,7 +1,13 @@ set(ENGINE_SOURCES Scripting/ScriptHost.cpp Scripting/InternalCalls.cpp + Assets/AssetCookManifest.cpp + Assets/AssetCooker.cpp Assets/AssimpImporter.cpp + Assets/CookedAssetRuntime.cpp + Assets/CookedMaterialAsset.cpp + Assets/CookedMeshAsset.cpp + Assets/CookedTextureAsset.cpp Assets/IAssetSource.cpp Assets/MeshAsset.cpp Assets/SceneFile.cpp diff --git a/Axiom/Session/EditorSession.cpp b/Axiom/Session/EditorSession.cpp index 162d7764..2084cce2 100644 --- a/Axiom/Session/EditorSession.cpp +++ b/Axiom/Session/EditorSession.cpp @@ -1,5 +1,6 @@ #include "Session/EditorSession.h" +#include "Assets/AssetCooker.h" #include "Assets/MeshAsset.h" #include @@ -15,6 +16,32 @@ namespace Axiom { namespace { +void CookMeshAssetBestEffort(const std::filesystem::path &ContentDir, + std::string_view RelativeAssetPath) { + if (ContentDir.empty() || RelativeAssetPath.empty()) { + return; + } + + const auto Cooked = Assets::CookMeshAsset(ContentDir, RelativeAssetPath); + if (!Cooked.has_value()) { + A_CORE_WARN("EditorSession: failed to cook mesh asset '{}'", + std::string(RelativeAssetPath)); + } +} + +void CookTextureAssetBestEffort(const std::filesystem::path &ContentDir, + std::string_view RelativeAssetPath) { + if (ContentDir.empty() || RelativeAssetPath.empty()) { + return; + } + + const auto Cooked = Assets::CookTextureAsset(ContentDir, RelativeAssetPath); + if (!Cooked.has_value()) { + A_CORE_WARN("EditorSession: failed to cook texture asset '{}'", + std::string(RelativeAssetPath)); + } +} + std::string DefaultUserDisplayName(SessionUserId User) { if (User.Value == 1) { return "Host"; @@ -953,6 +980,7 @@ bool EditorSession::ValidateCommand(const QueuedEditorCommand &QueuedCommand, FailureReason = "CreateMeshObject requires a configured content directory."; return false; } + CookMeshAssetBestEffort(m_ContentDir, CreateMeshCmd->AssetPath); const std::filesystem::path FullPath = m_ContentDir / CreateMeshCmd->AssetPath; const auto SceneData = Assets::LoadBasicMeshAsset(FullPath); if (!SceneData.has_value() || SceneData->Instances.empty()) { @@ -1479,6 +1507,7 @@ void EditorSession::HandleCommand(const QueuedEditorCommand &QueuedCommand, return; } + CookMeshAssetBestEffort(m_ContentDir, Command.AssetPath); const std::filesystem::path FullPath = m_ContentDir / Command.AssetPath; const auto SceneData = Assets::LoadBasicMeshAsset(FullPath); if (!SceneData.has_value() || SceneData->Instances.empty()) { @@ -1604,6 +1633,7 @@ void EditorSession::HandleCommand(const QueuedEditorCommand &QueuedCommand, A_CORE_WARN("SetMaterialTexture: content directory not configured"); return; } + CookTextureAssetBestEffort(m_ContentDir, Command.TextureAssetPath); const auto FullPath = m_ContentDir / Command.TextureAssetPath; auto Loaded = Assets::LoadTextureFromFile(FullPath); if (!Loaded) { diff --git a/Axiom/Session/StartupScene.cpp b/Axiom/Session/StartupScene.cpp index 0ef5e59d..0792bc06 100644 --- a/Axiom/Session/StartupScene.cpp +++ b/Axiom/Session/StartupScene.cpp @@ -1,5 +1,6 @@ #include "Session/StartupScene.h" +#include "Assets/AssetCooker.h" #include "Assets/IAssetSource.h" #include "Assets/MeshAsset.h" #include "Assets/SceneFile.h" @@ -244,7 +245,9 @@ std::unordered_map BuildObjectDetailsMap( std::vector BuildStartupSceneMeshInstances() { const Assets::LocalAssetSource ContentDir{AXIOM_CONTENT_DIR}; - const auto MeshPath = ContentDir.ResolveRelative("basicmesh.glb"); + const std::filesystem::path RelativeMeshPath = "basicmesh.glb"; + const auto MeshPath = ContentDir.ResolveRelative(RelativeMeshPath.string()); + Assets::CookMeshAsset(AXIOM_CONTENT_DIR, RelativeMeshPath); const auto SceneData = Assets::LoadBasicMeshAsset(MeshPath); if (!SceneData.has_value()) { A_CORE_ERROR("Failed to load startup mesh asset scene: {0}", diff --git a/Content/Cooked/AssetCookManifest.json b/Content/Cooked/AssetCookManifest.json new file mode 100644 index 00000000..5eb52fbb --- /dev/null +++ b/Content/Cooked/AssetCookManifest.json @@ -0,0 +1,9 @@ +{ + "entries": [ + {"assetId":6124137011624734461,"kind":"mesh","relativePath":"basicmesh.glb","cookedPath":"Cooked/basicmesh.wmesh","formatVersion":1,"sourceHash":10769525362242101033}, + {"assetId":18232260332646399803,"kind":"material","relativePath":"Generated/Materials/crate-1","cookedPath":"Cooked/Generated/Materials/crate-1.wmat","formatVersion":1,"sourceHash":7889859340775370464}, + {"assetId":813026631983381292,"kind":"texture","relativePath":"Engine/tf2 coconut.jpg","cookedPath":"Cooked/Engine/tf2 coconut.wtex","formatVersion":1,"sourceHash":2819454596902807771}, + {"assetId":13913950202207721753,"kind":"mesh","relativePath":"sponza_atrium_3.glb","cookedPath":"Cooked/sponza_atrium_3.wmesh","formatVersion":1,"sourceHash":13152113367551948137}, + {"assetId":5770549793718642602,"kind":"mesh","relativePath":"structure.glb","cookedPath":"Cooked/structure.wmesh","formatVersion":1,"sourceHash":11410644714278776383} + ] +} diff --git a/Content/Cooked/Engine/tf2 coconut.wtex b/Content/Cooked/Engine/tf2 coconut.wtex new file mode 100644 index 00000000..f3dcf884 Binary files /dev/null and b/Content/Cooked/Engine/tf2 coconut.wtex differ diff --git a/Content/Cooked/Generated/Materials/crate-1.wmat b/Content/Cooked/Generated/Materials/crate-1.wmat new file mode 100644 index 00000000..ef3f6e3a Binary files /dev/null and b/Content/Cooked/Generated/Materials/crate-1.wmat differ diff --git a/Content/Cooked/basicmesh.wmesh b/Content/Cooked/basicmesh.wmesh new file mode 100644 index 00000000..dab7e6da Binary files /dev/null and b/Content/Cooked/basicmesh.wmesh differ diff --git a/Content/Cooked/sponza_atrium_3.wmesh b/Content/Cooked/sponza_atrium_3.wmesh new file mode 100644 index 00000000..48b0f813 Binary files /dev/null and b/Content/Cooked/sponza_atrium_3.wmesh differ diff --git a/Content/Cooked/structure.wmesh b/Content/Cooked/structure.wmesh new file mode 100644 index 00000000..2cbb1ab7 Binary files /dev/null and b/Content/Cooked/structure.wmesh differ diff --git a/Content/sponza_atrium_3.glb b/Content/sponza_atrium_3.glb new file mode 100644 index 00000000..5d793b9e Binary files /dev/null and b/Content/sponza_atrium_3.glb differ diff --git a/Docs/DistributedWraithEngineDesign.md b/Docs/DistributedWraithEngineDesign.md index 48cb619f..d52e9da1 100644 --- a/Docs/DistributedWraithEngineDesign.md +++ b/Docs/DistributedWraithEngineDesign.md @@ -1031,7 +1031,7 @@ Progress update: - content browser replaced with a live server-driven implementation: `listAssets` is dispatched on connection, results populate grid/list views with mesh/texture filter tabs and a Refresh button - details panel now fetches schema dynamically via `getSchema` on each selection change; `className` badge is shown in the panel header; property `readOnly` flags gate transform editing - `SetProperty` dispatches per-property to the appropriate existing command (`RenameObject`, `SetObjectVisibility`, or `SetTransform`); vec3 properties patch the current transform so only the changed axis changes -- cooked asset containers remain future work; `IAssetSource` and `AssetId` establish the identity foundation they will build on +- `IAssetSource` and `AssetId` now also back the first cooked runtime path: `CookedAssetSource`, `AssetCookManifest`, and engine-owned cooked containers are implemented in the Phase 8 slice below ### Phase 6: C# Scripting - engine API assembly @@ -1118,9 +1118,10 @@ editor-only metadata included. - Texture reference table: `{uint8 slot, AssetId textureId}[]` — slots map to binding points in the material descriptor set layout #### 8.3 Import Pipeline Integration -- `IAssetImporter::Import()` now produces the appropriate `*.wmesh` / `*.wtex` / `*.wmat` file in `Content/Cooked/` in addition to writing the `.meta` sidecar -- `LocalAssetSource` resolves `AssetId → cooked path` at runtime; source file paths are only consulted by the importer and editor tools, never by the renderer -- Re-import regenerates the cooked binary; the `AssetId` (derived from the relative source path hash) remains stable across re-imports +- Target design: `IAssetImporter::Import()` should eventually produce the appropriate `*.wmesh` / `*.wtex` / `*.wmat` file in `Content/Cooked/` in addition to writing the `.meta` sidecar +- Current implementation: `CookMeshAsset`, `CookTextureAsset`, and `CookMaterialAsset` own the first cooking path; they are called from startup / scene reload / editor command flows while importer-driven cook orchestration remains follow-up work +- `CookedAssetSource` resolves `AssetId → cooked path` at runtime; `LocalAssetSource` remains the editor/source-facing index during the transition +- Re-import or re-cook regenerates the cooked binary; the `AssetId` (derived from the relative source path hash) remains stable across re-imports - `AssetCookManifest` (JSON alongside the cooked directory) maps `AssetId → {cookedPath, formatVersion, sourceHash}` for incremental cook decisions #### 8.4 Packaged Build Impact @@ -1128,6 +1129,27 @@ editor-only metadata included. - The runtime loader switches to `CookedAssetSource` (parallel to `LocalAssetSource`) which reads from the manifest and never touches source files - This is the direct enabler for Phase 11 (Packaging and Hosted Runtime Maturation) +Progress update: + +- first cooked asset containers are implemented: + - `.wmesh` stores versioned mesh instance payloads plus stable `AssetId` + - `.wtex` stores versioned raw RGBA texture payloads keyed by `AssetId` + - `.wmat` stores versioned material factors plus texture asset reference +- `AssetCookManifest` now lives at `Content/Cooked/AssetCookManifest.json` and is populated for mesh, texture, and material cooks +- `CookedAssetSource` is implemented parallel to `LocalAssetSource` and resolves cooked payloads by `AssetId` through the manifest +- runtime load paths now prefer cooked assets for mesh and texture loads, with source-file fallback preserved during the transition +- startup scene load, scene reload, `SetMeshAsset`, and `SetMaterialTexture` all exercise best-effort cook-first flows so normal editor usage continuously validates the cooked path +- scene persistence now emits and restores `materialAssetPath` entries backed by cooked `.wmat` files +- focused regression coverage now exists for: + - cooked mesh / texture / material binary round-trips + - manifest-backed cooked lookup resolution + - `SetMeshAsset` and `SetMaterialTexture` command-path cooking + - scene save/load round-trip of cooked material state +- remaining Phase 8 work is mostly hardening and packaging-facing: + - importer-driven cook orchestration rather than today’s best-effort command/load triggers + - richer material/texture reference tables and GPU-oriented texture layout if needed + - packaged runtime path that excludes source content entirely + ### Phase 9: Networking Refactor Scope: Replace the current bespoke WebSocket/HTTP signaling and data-channel transport @@ -1215,7 +1237,7 @@ Likely targets based on current trajectory: - CI passes without modifications to test code ### Phase 11: Packaging and Hosted Runtime Maturation -- packaged desktop builds from cooked content (depends on Phase 8 binary asset formats) +- packaged desktop builds from cooked content (depends on finishing the remaining Phase 8 packaging/runtime cutover work) - hosted session deployment descriptors - sample project proving shared runtime path via `CookedAssetSource` @@ -1300,7 +1322,8 @@ Progress update: - Collaboration v1 is complete: object locking prevents simultaneous gizmo conflicts, presence roster shows connected users, and the heartbeat/timeout loop handles hard tab closes - Phase 5 (Reflection and Asset Evolution) is complete: `AssetId` stable identity, `IAssetSource` / `LocalAssetSource` VFS, `ListAssets` / `GetSchema` / `SetProperty` / `SaveScene` commands, `SceneFile` JSON persistence, content browser wired to live asset catalogue, details panel schema-driven, toolbar Save button with success/failure animation - Phase 7 (Asset Pipeline) is complete: `SetMeshAssetCommand` wires any discovered `.glb`/`.gltf`/`.fbx`/`.obj` to any `SceneMeshObject` with scene-file persistence; `SetLightPropertiesCommand` drives a Blinn-Phong directional light from `SceneLight` world position; `SetMaterialPropertiesCommand` exposes `BaseColorFactor`/`Metallic`/`Roughness` push constants end-to-end through the inspector; `SetMaterialTextureCommand` assigns PNG/JPG textures to mesh base-color slots with persistence, inspector display, and drag-drop from both the content browser and outliner; FBX/OBJ import is implemented via assimp with embedded and external texture handling; the content browser accepts OS file drag-drop and a file picker Import button that upload to `POST /assets/upload`; texture thumbnail previews are served by the remote viewport server; the content browser navigates folders non-recursively; 17 new tests cover all new commands, events, and the `CreateObject`→`SetMeshAsset` runtime-creation path -- the next step is Phase 8: binary asset formats (`.wmesh`, `.wtex`, `.wmat`), the cook pipeline, and `CookedAssetSource` +- Phase 8 (Binary Asset Formats) first slice is now implemented: `.wmesh`, `.wtex`, and `.wmat` cooked formats exist; `AssetCookManifest` and `CookedAssetSource` resolve cooked content by stable `AssetId`; startup, scene reload, and mesh/texture editing flows all prefer cooked payloads while preserving source fallback; scene persistence now round-trips cooked material state through `materialAssetPath` +- the next step is packaging/runtime cutover work: finish removing source-content assumptions from packaged runs and make importer-driven cooking the primary freshness path That slice proves the core thesis: diff --git a/Tests/CMakeLists.txt b/Tests/CMakeLists.txt index 75d47dde..0645bdff 100644 --- a/Tests/CMakeLists.txt +++ b/Tests/CMakeLists.txt @@ -1,4 +1,5 @@ add_executable(AxiomTests + CookedAssetTests.cpp LayerTests.cpp HeadlessProtocolTests.cpp MeshPickingTests.cpp diff --git a/Tests/CookedAssetTests.cpp b/Tests/CookedAssetTests.cpp new file mode 100644 index 00000000..894e3046 --- /dev/null +++ b/Tests/CookedAssetTests.cpp @@ -0,0 +1,197 @@ +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace { + +void EnsureTempDirectory(const std::filesystem::path &Path) { + std::error_code Ec; + std::filesystem::create_directories(Path, Ec); + ASSERT_FALSE(Ec); +} + +std::filesystem::path MakeUniqueTempRoot(std::string_view Suffix) { + const auto Root = std::filesystem::temp_directory_path() / + std::filesystem::path("wraithengine-cooked-tests") / + std::filesystem::path(Suffix); + std::error_code RemoveError; + std::filesystem::remove_all(Root, RemoveError); + EnsureTempDirectory(Root); + return Root; +} + +void CopyFileChecked(const std::filesystem::path &From, + const std::filesystem::path &To) { + EnsureTempDirectory(To.parent_path()); + std::error_code Ec; + std::filesystem::copy_file(From, To, std::filesystem::copy_options::overwrite_existing, + Ec); + ASSERT_FALSE(Ec); +} + +} // namespace + +TEST(CookedAssetTests, CookedMeshRoundTripsThroughBinaryFormat) { + Axiom::MeshSceneData Source; + Source.Instances.push_back({ + .Name = "Cube", + .Mesh = + Axiom::MeshData{ + .Vertices = + { + {.Position = {1.0f, 2.0f, 3.0f, 1.0f}, + .Normal = {0.0f, 1.0f, 0.0f, 0.0f}, + .TexCoord = {0.25f, 0.75f}}, + {.Position = {4.0f, 5.0f, 6.0f, 1.0f}, + .Normal = {1.0f, 0.0f, 0.0f, 0.0f}, + .TexCoord = {0.5f, 0.5f}}, + }, + .Indices = {0, 1, 0}, + .BoundsMin = {1.0f, 2.0f, 3.0f}, + .BoundsMax = {4.0f, 5.0f, 6.0f}, + }, + .Material = std::make_shared(), + .Transform = glm::mat4(1.0f), + }); + + const auto TempRoot = MakeUniqueTempRoot("roundtrip"); + const auto CookedPath = TempRoot / "mesh.wmesh"; + ASSERT_TRUE(Axiom::Assets::SaveCookedMeshAsset( + CookedPath, Axiom::Assets::ToCookedMeshSceneData(Source), + Axiom::AssetId{42})); + + const auto Loaded = Axiom::Assets::LoadCookedMeshAsset(CookedPath); + ASSERT_TRUE(Loaded.has_value()); + ASSERT_EQ(Loaded->Instances.size(), 1u); + EXPECT_EQ(Loaded->Instances[0].Name, "Cube"); + ASSERT_EQ(Loaded->Instances[0].Mesh.Vertices.size(), 2u); + ASSERT_EQ(Loaded->Instances[0].Mesh.Indices.size(), 3u); + EXPECT_FLOAT_EQ(Loaded->Instances[0].Mesh.Vertices[0].Position.x, 1.0f); + EXPECT_FLOAT_EQ(Loaded->Instances[0].Mesh.Vertices[1].TexCoord.x, 0.5f); + EXPECT_EQ(Loaded->Instances[0].Mesh.Indices[1], 1u); + EXPECT_FLOAT_EQ(Loaded->Instances[0].Mesh.BoundsMax.z, 6.0f); +} + +TEST(CookedAssetTests, CookMeshAssetWritesManifestAndCookedLookupResolves) { + const auto TempRoot = MakeUniqueTempRoot("manifest"); + const auto ContentRoot = TempRoot / "Content"; + EnsureTempDirectory(ContentRoot); + + CopyFileChecked(std::filesystem::path(AXIOM_CONTENT_DIR) / "basicmesh.glb", + ContentRoot / "basicmesh.glb"); + + const auto Entry = + Axiom::Assets::CookMeshAsset(ContentRoot, std::filesystem::path("basicmesh.glb")); + ASSERT_TRUE(Entry.has_value()); + EXPECT_EQ(Entry->Kind, Axiom::Assets::AssetKind::Mesh); + EXPECT_EQ(Entry->RelativePath, "basicmesh.glb"); + + const auto Manifest = Axiom::Assets::LoadAssetCookManifest( + ContentRoot / "Cooked" / "AssetCookManifest.json"); + ASSERT_TRUE(Manifest.has_value()); + ASSERT_EQ(Manifest->Entries.size(), 1u); + EXPECT_EQ(Manifest->Entries[0].Id.Value, Entry->Id.Value); + + const Axiom::Assets::CookedAssetSource Cooked(ContentRoot); + const auto Resolved = Cooked.Resolve(Entry->Id); + ASSERT_TRUE(Resolved.has_value()); + EXPECT_EQ(Resolved->extension(), ".wmesh"); + + const auto Loaded = Axiom::Assets::LoadBasicMeshAsset(ContentRoot / "basicmesh.glb"); + ASSERT_TRUE(Loaded.has_value()); + EXPECT_FALSE(Loaded->Instances.empty()); +} + +TEST(CookedAssetTests, CookTextureAssetWritesManifestAndCookedLookupResolves) { + const auto TempRoot = MakeUniqueTempRoot("texture-manifest"); + const auto ContentRoot = TempRoot / "Content"; + EnsureTempDirectory(ContentRoot / "Engine"); + + CopyFileChecked(std::filesystem::path(AXIOM_CONTENT_DIR) / "Engine" / + "tf2 coconut.jpg", + ContentRoot / "Engine" / "tf2 coconut.jpg"); + + const auto Entry = Axiom::Assets::CookTextureAsset( + ContentRoot, std::filesystem::path("Engine/tf2 coconut.jpg")); + ASSERT_TRUE(Entry.has_value()); + EXPECT_EQ(Entry->Kind, Axiom::Assets::AssetKind::Texture); + EXPECT_EQ(Entry->RelativePath, "Engine/tf2 coconut.jpg"); + + const auto Manifest = Axiom::Assets::LoadAssetCookManifest( + ContentRoot / "Cooked" / "AssetCookManifest.json"); + ASSERT_TRUE(Manifest.has_value()); + ASSERT_EQ(Manifest->Entries.size(), 1u); + EXPECT_EQ(Manifest->Entries[0].Id.Value, Entry->Id.Value); + + const Axiom::Assets::CookedAssetSource Cooked(ContentRoot); + const auto Resolved = Cooked.Resolve(Entry->Id); + ASSERT_TRUE(Resolved.has_value()); + EXPECT_EQ(Resolved->extension(), ".wtex"); + + const auto Loaded = + Axiom::Assets::LoadTextureFromFile(ContentRoot / "Engine" / "tf2 coconut.jpg"); + ASSERT_TRUE(Loaded != nullptr); + EXPECT_TRUE(Loaded->IsValid()); +} + +TEST(CookedAssetTests, CookedMaterialRoundTripsThroughBinaryFormat) { + const auto TempRoot = MakeUniqueTempRoot("material-roundtrip"); + const auto CookedPath = TempRoot / "material.wmat"; + const Axiom::Assets::CookedMaterialData Source{ + .BaseColorFactor = glm::vec4(0.2f, 0.4f, 0.6f, 1.0f), + .Metallic = 0.7f, + .Roughness = 0.15f, + .TextureAssetPath = "Engine/tf2 coconut.jpg", + }; + + ASSERT_TRUE( + Axiom::Assets::SaveCookedMaterialAsset(CookedPath, Source, Axiom::AssetId{77})); + const auto Loaded = Axiom::Assets::LoadCookedMaterialAsset(CookedPath); + ASSERT_TRUE(Loaded.has_value()); + EXPECT_FLOAT_EQ(Loaded->BaseColorFactor.r, 0.2f); + EXPECT_FLOAT_EQ(Loaded->BaseColorFactor.g, 0.4f); + EXPECT_FLOAT_EQ(Loaded->BaseColorFactor.b, 0.6f); + EXPECT_FLOAT_EQ(Loaded->Metallic, 0.7f); + EXPECT_FLOAT_EQ(Loaded->Roughness, 0.15f); + EXPECT_EQ(Loaded->TextureAssetPath, "Engine/tf2 coconut.jpg"); +} + +TEST(CookedAssetTests, CookMaterialAssetWritesManifestAndCookedLookupResolves) { + const auto TempRoot = MakeUniqueTempRoot("material-manifest"); + const auto ContentRoot = TempRoot / "Content"; + EnsureTempDirectory(ContentRoot); + + const auto Entry = Axiom::Assets::CookMaterialAsset( + ContentRoot, std::filesystem::path("Generated/Materials/crate-1"), + {.BaseColorFactor = glm::vec4(0.8f, 0.2f, 0.1f, 1.0f), + .Metallic = 0.9f, + .Roughness = 0.05f, + .TextureAssetPath = "Engine/tf2 coconut.jpg"}); + ASSERT_TRUE(Entry.has_value()); + EXPECT_EQ(Entry->Kind, Axiom::Assets::AssetKind::Material); + + const auto Manifest = Axiom::Assets::LoadAssetCookManifest( + ContentRoot / "Cooked" / "AssetCookManifest.json"); + ASSERT_TRUE(Manifest.has_value()); + ASSERT_EQ(Manifest->Entries.size(), 1u); + + const Axiom::Assets::CookedAssetSource Cooked(ContentRoot); + const auto Resolved = Cooked.Resolve(Entry->Id); + ASSERT_TRUE(Resolved.has_value()); + EXPECT_EQ(Resolved->extension(), ".wmat"); + + const auto Loaded = Axiom::Assets::LoadCookedMaterialAsset(*Resolved); + ASSERT_TRUE(Loaded.has_value()); + EXPECT_FLOAT_EQ(Loaded->Metallic, 0.9f); + EXPECT_EQ(Loaded->TextureAssetPath, "Engine/tf2 coconut.jpg"); +} diff --git a/Tests/SceneLifecycleTests.cpp b/Tests/SceneLifecycleTests.cpp index 0a1949dd..e85421ff 100644 --- a/Tests/SceneLifecycleTests.cpp +++ b/Tests/SceneLifecycleTests.cpp @@ -1,5 +1,7 @@ #include +#include +#include #include #include @@ -1143,3 +1145,179 @@ TEST(SceneLifecycleTests, SetMeshAsset_CreatesInstanceForRuntimeCreatedMesh) { ASSERT_NE(It, Instances.end()); EXPECT_EQ(It->AssetRelativePath, "basicmesh.glb"); } + +TEST(SceneLifecycleTests, SetMeshAsset_CooksMeshAssetManifestEntry) { + EnsureLogInitialized(); + Axiom::EditorSession Session = MakeWorldSession(); + + const auto TempRoot = + std::filesystem::temp_directory_path() / "wraithengine-scene-cook-test"; + std::error_code RemoveError; + std::filesystem::remove_all(TempRoot, RemoveError); + std::filesystem::create_directories(TempRoot / "Content"); + std::filesystem::copy_file(std::filesystem::path(AXIOM_CONTENT_DIR) / "basicmesh.glb", + TempRoot / "Content" / "basicmesh.glb", + std::filesystem::copy_options::overwrite_existing); + Session.SetContentDir(TempRoot / "Content"); + + RecordingSubscriber Subscriber; + Session.Subscribe(&Subscriber); + + Session.Submit(MakeContext(), + {.Payload = Axiom::CreateObjectCommand{.TemplateId = "Mesh"}}); + Session.Tick(); + const auto *Created = FindEvent(Subscriber.Events); + ASSERT_NE(Created, nullptr); + const std::string ObjectId = Created->ObjectId; + Subscriber.Events.clear(); + + Session.Submit(MakeContext(), + {.Payload = Axiom::SetMeshAssetCommand{ + .ObjectId = ObjectId, + .AssetPath = "basicmesh.glb", + }}); + Session.Tick(); + + ASSERT_EQ(FindEvent(Subscriber.Events), nullptr); + const auto ManifestPath = + TempRoot / "Content" / "Cooked" / "AssetCookManifest.json"; + const auto Manifest = Axiom::Assets::LoadAssetCookManifest(ManifestPath); + ASSERT_TRUE(Manifest.has_value()); + ASSERT_EQ(Manifest->Entries.size(), 1u); + EXPECT_EQ(Manifest->Entries[0].RelativePath, "basicmesh.glb"); + EXPECT_EQ(std::filesystem::path(Manifest->Entries[0].CookedPath).extension(), + ".wmesh"); +} + +TEST(SceneLifecycleTests, SetMaterialTexture_CooksTextureAssetManifestEntry) { + EnsureLogInitialized(); + + auto Mat = std::make_shared(); + Axiom::EditorSession Session(Axiom::SessionId{1}); + Session.SetSceneItems({{ + .Id = "crate-1", + .DisplayName = "Crate", + .Kind = Axiom::EditorSceneItemKind::Mesh, + .Visible = true, + .Children = {}, + }}); + Session.SetObjectDetails({{ + .ObjectId = "crate-1", + .DisplayName = "Crate", + .Kind = Axiom::EditorSceneItemKind::Mesh, + .Visible = true, + .SupportsTransform = true, + .TransformReadOnly = false, + .Material = Axiom::EditorMaterialProperties{}, + }}); + Session.SetSceneMeshInstances({{ + .ObjectId = "crate-1", + .Mesh = {}, + .Material = Mat, + .RenderPath = Axiom::MeshRenderPath::Graphics, + .Transform = glm::mat4(1.0f), + }}); + + const auto TempRoot = + std::filesystem::temp_directory_path() / "wraithengine-texture-cook-test"; + std::error_code RemoveError; + std::filesystem::remove_all(TempRoot, RemoveError); + std::filesystem::create_directories(TempRoot / "Content" / "Engine"); + std::filesystem::copy_file( + std::filesystem::path(AXIOM_CONTENT_DIR) / "Engine" / "tf2 coconut.jpg", + TempRoot / "Content" / "Engine" / "tf2 coconut.jpg", + std::filesystem::copy_options::overwrite_existing); + Session.SetContentDir(TempRoot / "Content"); + + RecordingSubscriber Subscriber; + Session.Subscribe(&Subscriber); + + Session.Submit(MakeContext(), + {.Payload = Axiom::SetMaterialTextureCommand{ + .ObjectId = "crate-1", + .TextureAssetPath = "Engine/tf2 coconut.jpg", + }}); + Session.Tick(); + + ASSERT_EQ(FindEvent(Subscriber.Events), nullptr); + const auto Manifest = + Axiom::Assets::LoadAssetCookManifest(TempRoot / "Content" / "Cooked" / + "AssetCookManifest.json"); + ASSERT_TRUE(Manifest.has_value()); + ASSERT_EQ(Manifest->Entries.size(), 1u); + EXPECT_EQ(Manifest->Entries[0].Kind, Axiom::Assets::AssetKind::Texture); + EXPECT_EQ(Manifest->Entries[0].RelativePath, "Engine/tf2 coconut.jpg"); + EXPECT_EQ(std::filesystem::path(Manifest->Entries[0].CookedPath).extension(), + ".wtex"); +} + +TEST(SceneLifecycleTests, SceneFile_SaveLoadRoundTripsCookedMaterialState) { + EnsureLogInitialized(); + + const auto TempRoot = + std::filesystem::temp_directory_path() / "wraithengine-material-scene-test"; + std::error_code RemoveError; + std::filesystem::remove_all(TempRoot, RemoveError); + std::filesystem::create_directories(TempRoot / "Content" / "Engine"); + std::filesystem::copy_file( + std::filesystem::path(AXIOM_CONTENT_DIR) / "basicmesh.glb", + TempRoot / "Content" / "basicmesh.glb", + std::filesystem::copy_options::overwrite_existing); + std::filesystem::copy_file( + std::filesystem::path(AXIOM_CONTENT_DIR) / "Engine" / "tf2 coconut.jpg", + TempRoot / "Content" / "Engine" / "tf2 coconut.jpg", + std::filesystem::copy_options::overwrite_existing); + + Axiom::EditorSceneState Scene; + Scene.Items = {{ + .Id = "crate-1", + .DisplayName = "Crate", + .Kind = Axiom::EditorSceneItemKind::Mesh, + .Visible = true, + .Children = {}, + }}; + Scene.ObjectDetailsById["crate-1"] = Axiom::EditorObjectDetails{ + .ObjectId = "crate-1", + .DisplayName = "Crate", + .Kind = Axiom::EditorSceneItemKind::Mesh, + .Visible = true, + .SupportsTransform = true, + .TransformReadOnly = false, + .Transform = Axiom::EditorTransformDetails{}, + .Material = Axiom::EditorMaterialProperties{ + .BaseColorFactor = glm::vec4(0.8f, 0.2f, 0.1f, 1.0f), + .Metallic = 0.9f, + .Roughness = 0.05f, + .TextureAssetPath = std::string("Engine/tf2 coconut.jpg"), + }, + }; + + auto Mat = std::make_shared(); + Mat->BaseColorFactor = glm::vec4(0.8f, 0.2f, 0.1f, 1.0f); + Mat->Metallic = 0.9f; + Mat->Roughness = 0.05f; + Mat->TextureAssetPath = "Engine/tf2 coconut.jpg"; + Scene.MeshInstances = {{ + .ObjectId = "crate-1", + .Mesh = {}, + .Material = Mat, + .RenderPath = Axiom::MeshRenderPath::Graphics, + .Transform = glm::mat4(1.0f), + .AssetRelativePath = "basicmesh.glb", + }}; + + const auto ScenePath = TempRoot / "Content" / "scene.json"; + ASSERT_TRUE(Axiom::Assets::SaveSceneToFile(ScenePath, Scene)); + + const auto Loaded = Axiom::Assets::LoadSceneFromFile(ScenePath); + ASSERT_TRUE(Loaded.has_value()); + const auto DetailsIt = Loaded->ObjectDetailsById.find("crate-1"); + ASSERT_NE(DetailsIt, Loaded->ObjectDetailsById.end()); + ASSERT_TRUE(DetailsIt->second.Material.has_value()); + EXPECT_FLOAT_EQ(DetailsIt->second.Material->BaseColorFactor.r, 0.8f); + EXPECT_FLOAT_EQ(DetailsIt->second.Material->Metallic, 0.9f); + EXPECT_FLOAT_EQ(DetailsIt->second.Material->Roughness, 0.05f); + ASSERT_TRUE(DetailsIt->second.Material->TextureAssetPath.has_value()); + EXPECT_EQ(*DetailsIt->second.Material->TextureAssetPath, + "Engine/tf2 coconut.jpg"); +}