/* Crafter®.Asset Copyright (C) 2026 Catcrafts® catcrafts.net This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License version 3.0 as published by the Free Software Foundation; This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA */ export module Crafter.Asset:Mesh; import :Compression; import Crafter.Math; import std; namespace fs = std::filesystem; export namespace Crafter { // Minimal Wavefront .mtl record. Only diffuse-map path is captured; // normal/specular/etc. would extend this in a backward-compatible way. // `mapKd` is verbatim from the file (relative to the .mtl's directory) // and empty if the material defined no diffuse texture. struct MtlMaterial { std::string mapKd; }; // Parse a .mtl file into { materialName → MtlMaterial }. Strips trailing // \r/\n/spaces on names + paths so DOS-line-ending files match the // `usemtl` tokens in their sibling .obj. Materials without `map_Kd` // are still present in the map with an empty `mapKd`. inline std::unordered_map LoadMTL(fs::path path) { std::ifstream file(path); std::unordered_map result; std::string current; std::string line; auto trim = [](std::string& s) { while (!s.empty() && (s.back() == '\r' || s.back() == '\n' || s.back() == ' ' || s.back() == '\t')) s.pop_back(); while (!s.empty() && (s.front() == ' ' || s.front() == '\t')) s.erase(s.begin()); }; while (std::getline(file, line)) { if (line.starts_with("newmtl ")) { current = line.substr(7); trim(current); result.try_emplace(current); } else if (line.starts_with("map_Kd ") && !current.empty()) { std::string val = line.substr(7); trim(val); result[current].mapKd = std::move(val); } } return result; } struct __attribute__((packed)) VertexNormalTangentUVPacked { Vector normal; Vector tangent; Vector uv; }; struct VertexNormalTangentUV { Vector normal; Vector tangent; Vector uv; }; // GDeflate-compressed counterpart of MeshAsset::Save output. Three // regions: [vertex, index, data]. dataCount==0 leaves the data region // empty (zero compressedSize/decompressedSize). dataStride records sizeof(T) // at compress time so consumers can validate. struct CompressedMeshAsset { std::uint32_t vertexCount = 0; std::uint32_t indexCount = 0; std::uint32_t dataCount = 0; std::uint32_t dataStride = 0; Compression::CompressedBlob blob; }; namespace MeshAssetFormat { inline constexpr char magic[4] = {'C', 'G', 'D', 'M'}; inline constexpr std::uint32_t version = 1; } inline CompressedMeshAsset LoadCompressedMesh(fs::path path) { std::ifstream file(path, std::ios::binary); char magic[4]; file.read(magic, 4); if (std::memcmp(magic, MeshAssetFormat::magic, 4) != 0) { Compression::Fatal("LoadCompressedMesh: bad magic on " + path.string()); } std::uint32_t version = 0; file.read(reinterpret_cast(&version), sizeof(version)); if (version != MeshAssetFormat::version) { Compression::Fatal("LoadCompressedMesh: unsupported version on " + path.string()); } CompressedMeshAsset out; file.read(reinterpret_cast(&out.vertexCount), sizeof(out.vertexCount)); file.read(reinterpret_cast(&out.indexCount), sizeof(out.indexCount)); file.read(reinterpret_cast(&out.dataCount), sizeof(out.dataCount)); file.read(reinterpret_cast(&out.dataStride), sizeof(out.dataStride)); out.blob = Compression::ReadBlob(file); return out; } template struct MeshAsset { std::vector> vertexes; std::vector indexes; std::vector datas; void Save(fs::path path) { std::ofstream file(path, std::ios::binary); std::uint32_t vertexCount = vertexes.size(); std::uint32_t indexCount = indexes.size(); std::uint32_t dataCount = datas.size(); file.write(reinterpret_cast(&vertexCount), sizeof(vertexCount)); file.write(reinterpret_cast(&indexCount), sizeof(indexCount)); file.write(reinterpret_cast(&dataCount), sizeof(dataCount)); file.write(reinterpret_cast(vertexes.data()), vertexCount * sizeof(Vector)); file.write(reinterpret_cast(indexes.data()), indexCount * sizeof(std::uint32_t)); file.write(reinterpret_cast(datas.data()), dataCount * sizeof(T)); } void SaveCompressed(fs::path path) const { std::array, 3> streams = { std::as_bytes(std::span(vertexes)), std::as_bytes(std::span(indexes)), std::as_bytes(std::span(datas)), }; Compression::CompressedBlob blob = Compression::CompressStreams(streams); std::ofstream file(path, std::ios::binary); file.write(MeshAssetFormat::magic, 4); std::uint32_t version = MeshAssetFormat::version; std::uint32_t vc = static_cast(vertexes.size()); std::uint32_t ic = static_cast(indexes.size()); std::uint32_t dc = static_cast(datas.size()); std::uint32_t stride = static_cast(sizeof(T)); file.write(reinterpret_cast(&version), sizeof(version)); file.write(reinterpret_cast(&vc), sizeof(vc)); file.write(reinterpret_cast(&ic), sizeof(ic)); file.write(reinterpret_cast(&dc), sizeof(dc)); file.write(reinterpret_cast(&stride), sizeof(stride)); Compression::WriteBlob(file, blob); } static MeshAsset Load(fs::path path) { MeshAsset mesh; std::ifstream file(path, std::ios::binary); std::uint32_t vertexCount = 0; std::uint32_t indexCount = 0; std::uint32_t dataCount = 0; file.read(reinterpret_cast(&vertexCount), sizeof(vertexCount)); file.read(reinterpret_cast(&indexCount), sizeof(indexCount)); file.read(reinterpret_cast(&dataCount), sizeof(dataCount)); mesh.vertexes.resize(vertexCount); mesh.indexes.resize(indexCount); mesh.datas.resize(dataCount); file.read(reinterpret_cast(mesh.vertexes.data()), vertexCount * sizeof(Vector)); file.read(reinterpret_cast(mesh.indexes.data()), indexCount * sizeof(std::uint32_t)); file.read(reinterpret_cast(mesh.datas.data()), dataCount * sizeof(T)); return mesh; } // Parse a .obj split by `usemtl` directives. Returns one // MeshAsset per material group (in encounter order), each with // its own deduplicated vertex/index/data arrays — i.e. shared // vertices across materials are duplicated so each MeshAsset is // self-contained and can become a standalone BLAS. Dedup is // O(1)-per-vertex via a hash map keyed on (vIdx, vtIdx, vnIdx), // unlike LoadOBJ's linear-scan dedup (which is O(N²) and gets // pathological on Sponza-class inputs). // // Faces appearing before any `usemtl` accumulate under the empty // string material name; consumers can decide whether to drop them // or treat them as a default. Non-triangle faces are fan-triangulated // around their first vertex. static std::vector>> LoadOBJSplit(fs::path path) requires (std::same_as || std::same_as) { std::ifstream file(path); std::vector> positions; std::vector> normals; std::vector> uvs; std::vector>> result; std::unordered_map matIndex; std::vector> dedupPerMat; std::size_t curMat = static_cast(-1); auto useMaterial = [&](std::string name) { while (!name.empty() && (name.back() == '\r' || name.back() == '\n' || name.back() == ' ' || name.back() == '\t')) name.pop_back(); auto it = matIndex.find(name); if (it != matIndex.end()) { curMat = it->second; return; } matIndex.emplace(name, result.size()); result.emplace_back(std::move(name), MeshAsset{}); dedupPerMat.emplace_back(); curMat = result.size() - 1; }; std::string line; while (std::getline(file, line)) { if (line.starts_with("usemtl ")) { useMaterial(line.substr(7)); } else if (line.starts_with("vt ")) { std::istringstream iss(line.substr(3)); Vector uv; iss >> uv.x >> uv.y; uvs.push_back(uv); } else if (line.starts_with("vn ")) { std::istringstream iss(line.substr(3)); Vector n; iss >> n.x >> n.y >> n.z; normals.push_back(n); } else if (line.starts_with("v ")) { std::istringstream iss(line.substr(2)); Vector p; iss >> p.x >> p.y >> p.z; positions.push_back(Vector{p.x, p.y, p.z}); } else if (line.starts_with("f ")) { if (curMat == static_cast(-1)) useMaterial(std::string{}); MeshAsset& mesh = result[curMat].second; auto& dedup = dedupPerMat[curMat]; std::istringstream iss(line.substr(2)); std::string vg; std::vector faceIndices; while (iss >> vg) { std::istringstream vss(vg); std::string vs, vts, vns; std::getline(vss, vs, '/'); std::getline(vss, vts, '/'); std::getline(vss, vns, '/'); std::uint32_t vi = vs.empty() ? 0u : static_cast(std::stoi(vs) - 1); std::uint32_t vti = vts.empty() ? 0u : static_cast(std::stoi(vts) - 1); std::uint32_t vni = vns.empty() ? 0u : static_cast(std::stoi(vns) - 1); std::uint64_t key = (static_cast(vi) << 42) | (static_cast(vti) << 21) | static_cast(vni); if (auto it = dedup.find(key); it != dedup.end()) { faceIndices.push_back(it->second); } else { std::uint32_t newIdx = static_cast(mesh.vertexes.size()); const Vector& p = positions[vi]; const Vector& n = normals[vni]; const Vector& uv = uvs[vti]; mesh.vertexes.push_back(Vector{p.x, p.y, p.z}); mesh.datas.push_back(T{n, {0,0,0}, uv}); dedup.emplace(key, newIdx); faceIndices.push_back(newIdx); } } for (std::size_t i = 1; i + 1 < faceIndices.size(); ++i) { mesh.indexes.push_back(faceIndices[0]); mesh.indexes.push_back(faceIndices[i]); mesh.indexes.push_back(faceIndices[i + 1]); } } } // Accumulate face tangents into each vertex's tangent slot, // then normalize. Mirrors LoadOBJ's tangent calculation but // guarded against degenerate UVs (the LoadOBJ form NaNs out // silently — fine for the cube but blows up some Sponza tris). for (auto& entry : result) { MeshAsset& mesh = entry.second; for (std::uint32_t i = 0; i + 2 < mesh.indexes.size(); i += 3) { std::uint32_t i0 = mesh.indexes[i]; std::uint32_t i1 = mesh.indexes[i + 1]; std::uint32_t i2 = mesh.indexes[i + 2]; Vector edge1 = mesh.vertexes[i1] - mesh.vertexes[i0]; Vector edge2 = mesh.vertexes[i2] - mesh.vertexes[i0]; Vector dUV1 = mesh.datas[i1].uv - mesh.datas[i0].uv; Vector dUV2 = mesh.datas[i2].uv - mesh.datas[i0].uv; float denom = dUV1.x * dUV2.y - dUV1.y * dUV2.x; if (denom == 0.0f) continue; float f = 1.0f / denom; Vector tangent; tangent.x = f * (dUV2.y * edge1.x - dUV1.y * edge2.x); tangent.y = f * (dUV2.y * edge1.y - dUV1.y * edge2.y); tangent.z = f * (dUV2.y * edge1.z - dUV1.y * edge2.z); tangent.Normalize(); mesh.datas[i0].tangent += tangent; mesh.datas[i1].tangent += tangent; mesh.datas[i2].tangent += tangent; } for (T& v : mesh.datas) { v.tangent.Normalize(); } } return result; } static MeshAsset LoadOBJ(fs::path path) requires (std::same_as || std::same_as) { std::ifstream file(path); MeshAsset mesh; std::string line; std::vector> positions; std::vector> normals; std::vector> uvs; while (std::getline(file, line)) { if (line.substr(0, 2) == "vt") { std::istringstream iss(line.substr(3)); Vector uv; iss >> uv.x >> uv.y; uvs.push_back(uv); } else if (line.substr(0, 2) == "vn") { std::istringstream iss(line.substr(3)); Vector normal; iss >> normal.x >> normal.y >> normal.z; normals.push_back(normal); } else if (line.substr(0, 1) == "v") { std::istringstream iss(line.substr(2)); Vector position; iss >> position.x >> position.y >> position.z; positions.push_back(position); } else if (line.substr(0, 1) == "f") { std::istringstream iss(line.substr(2)); std::string vertexGroup; while (iss >> vertexGroup) { std::istringstream vss(vertexGroup); std::string vIndex, vtIndex, vnIndex; std::getline(vss, vIndex, '/'); std::getline(vss, vtIndex, '/'); std::getline(vss, vnIndex, '/'); Vector v = positions[std::stoi(vIndex) - 1]; Vector vt = uvs[std::stoi(vtIndex) - 1]; Vector vn = normals[std::stoi(vnIndex) - 1]; for (std::uint32_t i = 0; i < mesh.datas.size(); i++) { if (mesh.datas[i].normal == vn && mesh.datas[i].uv == vt && mesh.vertexes[i] == v) { mesh.indexes.push_back(i); goto skip; } } mesh.indexes.push_back(mesh.datas.size()); mesh.datas.push_back({vn, {0,0,0}, vt}); mesh.vertexes.push_back(v); skip:; } } } // Accumulate face normals and tangents for (std::uint32_t i = 0; i < mesh.indexes.size(); i += 3) { std::uint32_t i0 = mesh.indexes[i]; std::uint32_t i1 = mesh.indexes[i + 1]; std::uint32_t i2 = mesh.indexes[i + 2]; // Edges of the triangle Vector edge1 = mesh.vertexes[i1] - mesh.vertexes[i0]; Vector edge2 = mesh.vertexes[i2] - mesh.vertexes[i0]; // Texture coordinate deltas Vector deltaUV1 = mesh.datas[i1].uv - mesh.datas[i0].uv; Vector deltaUV2 = mesh.datas[i2].uv - mesh.datas[i0].uv; // Tangent calculation float f = 1.0f / (deltaUV1.x * deltaUV2.y - deltaUV1.y * deltaUV2.x); Vector tangent; tangent.x = f * (deltaUV2.y * edge1.x - deltaUV1.y * edge2.x); tangent.y = f * (deltaUV2.y * edge1.y - deltaUV1.y * edge2.y); tangent.z = f * (deltaUV2.y * edge1.z - deltaUV1.y * edge2.z); // Normalize tangent tangent.Normalize(); // Accumulate normals and tangents for each vertex mesh.datas[i0].tangent += tangent; mesh.datas[i1].tangent += tangent; mesh.datas[i2].tangent += tangent; } // Normalize vertex normals and tangents for (T& v : mesh.datas) { v.tangent.Normalize(); } return mesh; } }; template <> struct MeshAsset { std::vector> vertexes; std::vector indexes; void Save(fs::path path) { std::ofstream file(path, std::ios::binary); std::uint32_t vertexCount = vertexes.size(); std::uint32_t indexCount = indexes.size(); file.write(reinterpret_cast(&vertexCount), sizeof(vertexCount)); file.write(reinterpret_cast(&indexCount), sizeof(indexCount)); file.write(reinterpret_cast(vertexes.data()), vertexCount * sizeof(Vector)); file.write(reinterpret_cast(indexes.data()), indexCount * sizeof(std::uint32_t)); } void SaveCompressed(fs::path path) const { // Three regions to keep file format identical to the templated // variant; the data region is empty (skipped on the GPU path). std::array, 3> streams = { std::as_bytes(std::span(vertexes)), std::as_bytes(std::span(indexes)), std::span{}, }; Compression::CompressedBlob blob = Compression::CompressStreams(streams); std::ofstream file(path, std::ios::binary); file.write(MeshAssetFormat::magic, 4); std::uint32_t version = MeshAssetFormat::version; std::uint32_t vc = static_cast(vertexes.size()); std::uint32_t ic = static_cast(indexes.size()); std::uint32_t dc = 0; std::uint32_t stride = 0; file.write(reinterpret_cast(&version), sizeof(version)); file.write(reinterpret_cast(&vc), sizeof(vc)); file.write(reinterpret_cast(&ic), sizeof(ic)); file.write(reinterpret_cast(&dc), sizeof(dc)); file.write(reinterpret_cast(&stride), sizeof(stride)); Compression::WriteBlob(file, blob); } static MeshAsset Load(fs::path path) { MeshAsset mesh; std::ifstream file(path, std::ios::binary); std::uint32_t vertexCount = 0; std::uint32_t indexCount = 0; file.read(reinterpret_cast(&vertexCount), sizeof(vertexCount)); file.read(reinterpret_cast(&indexCount), sizeof(indexCount)); mesh.vertexes.resize(vertexCount); mesh.indexes.resize(indexCount); file.read(reinterpret_cast(mesh.vertexes.data()), vertexCount * sizeof(Vector)); file.read(reinterpret_cast(mesh.indexes.data()), indexCount * sizeof(std::uint32_t)); return mesh; } static MeshAsset LoadOBJ(fs::path path) { std::ifstream file(path); MeshAsset mesh; std::string line; while (std::getline(file, line)) { if (line.rfind("v ", 0) == 0) { std::istringstream iss(line.substr(2)); Vector position; iss >> position.x >> position.y >> position.z; mesh.vertexes.push_back(position); } else if (line.rfind("f ", 0) == 0) { std::istringstream iss(line.substr(2)); std::string vertexGroup; while (iss >> vertexGroup) { mesh.indexes.push_back(std::stoi(vertexGroup) - 1); } } } return mesh; } }; }