diff --git a/docs/dev/测试覆盖.md b/docs/dev/测试覆盖.md index e0f75a24..65f71f09 100644 --- a/docs/dev/测试覆盖.md +++ b/docs/dev/测试覆盖.md @@ -405,7 +405,7 @@ GPU 测试分两类,新建测试按此约定选择: ## 13b. API 明细:`model3d`(资源名 `3dmodel`) -用例:`test/model3d.cpp`(15)。CPU 解码 + 材质 API + 可选 GPU 渲染冒烟;上传见 `Graphics::newMeshFromAssimp` / `RenderSystem3D`。 +用例:`test/model3d.cpp`(17)。CPU 解码 + 材质 API + 可选 GPU 渲染冒烟;上传见 `Graphics::newMeshFromAssimp` / `RenderSystem3D`。 | API | 头文件 | 状态 | 用例 | 缺口 | |-----|--------|------|------|------| @@ -415,6 +415,8 @@ GPU 测试分两类,新建测试按此约定选择: | `Model3D::getName` | Model3D.h | 已覆盖 | `model3d.Model3D.getName` | | | `ModelData::getMeshCount` / `getVertexCount` / `getFaceCount` / `empty` / `getMesh` / `getScene` | ModelData.h | 已覆盖 | `model3d.newModelData.objFromMemory` | | | `ModelData::getMaterialCount` / `hasNormals` / `hasTexCoords` | ModelData.h | 已覆盖 | `model3d.newModelData.objFromMemory`(合法 mesh:Assimp OBJ 导入器总是计算法线,仅在有 `vt` 时才有纹理坐标)、`model3d.newModelData.invalidMeshIndexThrows`(非法 index 均抛错) | | +| `ModelData` UV 通道 / 切线 / 顶点色属性 | ModelData.h | 已覆盖 | `model3d.newModelData.objFromMemory` | 内建 PBR 仍默认采样 UV0;其余通道供自定义管线与烘焙使用 | +| `Model3D::bakeModel` / `.evmodel` 往返 | Model3D.h | 已覆盖 | `model3d.evmodel.roundTrip` | 当前为稳定容器格式,不是 GPU-ready 几何压缩格式 | | `ModelData` 非法 mesh index | ModelData.h | 已覆盖 | `model3d.newModelData.invalidMeshIndexThrows`(`getVertexCount`/`getFaceCount`/`hasNormals`/`hasTexCoords` 抛异常;`getMesh` 返回 `nullptr`) | | | `ModelData` 材质 API(base color / metallic / roughness / opacity / two-sided) | ModelData.h | 已覆盖 | `model3d.materialApi.objMtl`(OBJ DIFFUSE 回退 + 默认 PBR 因子)、`model3d.materialApi.gltf`(CesiumMan BASE_COLOR + 显式 metallic 0 / roughness 1) | 非法 matIndex 直接返回默认值,未逐项断言 | | `ModelData` 贴图查询(slot/path/embedded index) | ModelData.h | 已覆盖 | `model3d.materialApi.objMtl`(无贴图负路径 + 未知类型名拒绝)、`model3d.materialApi.gltf`(外部 base_color 路径)、`model3d.render.gltfEmbeddedUvOrientation`(内嵌 `*0` + 解码 RGBA8) | | diff --git a/docs/usr/modules/model3d.md b/docs/usr/modules/model3d.md index a2af1da3..fded50ea 100644 --- a/docs/usr/modules/model3d.md +++ b/docs/usr/modules/model3d.md @@ -61,11 +61,15 @@ local renderable = models.createRenderable(gfx, md, 0); 下列方法名来自当前 Squirrel 绑定;同一模块创建的辅助对象(例如 `World`、`Body`、`Source`)的方法也列在这里。 - `empty()`、`getFaceCount()`、`getMaterialCount()`、`getMeshCount()`、`getName()`、`getVertexCount()`、`hasNormals()`、`hasTexCoords()` +- 顶点流:`getTexCoordChannelCount()`、`hasTexCoordChannel()`、`getTexCoord()`、`hasTangents()`、`getTangent()`、`getBitangent()`、`getVertexColorChannelCount()`、`hasVertexColorChannel()`、`getVertexColor()` - 材质:`getMaterialIndex()`、`getMaterialName()`、`getMaterialBaseColorR/G/B/A()`、`getMaterialMetallicFactor()`、`getMaterialRoughnessFactor()`、`getMaterialOpacity()`、`getMaterialTwoSided()`、`getMaterialAlphaMode()`、`getMaterialAlphaCutoff()`、`getMaterialTextureSlotCount()`、`getMaterialTexturePath()`、`getMaterialTextureEmbeddedIndex()` - 内嵌贴图:`getEmbeddedTextureCount()`、`getEmbeddedTextureName()`、`getEmbeddedTextureWidth()`、`getEmbeddedTextureHeight()`、`getEmbeddedTextureImageData()` - 蒙皮:`hasBones()`、`getBoneCount()`、`getBoneName()`、`getInverseBindMatrixElement()`、`getBoneWeightCount()`、`getBoneWeightVertex()`、`getBoneWeightValue()` - 动画剪辑:`getAnimationCount()`、`getAnimationName()` - `newModelData()`、`newModelDataFromFile()`、`createRenderable()` +- 离线封装:`bakeModel(sourcePath, destinationPath)` 将自包含 GLB/FBX 打包为 `.evmodel`;部署时仍用 `newModelDataFromFile()` 透明加载。 + +`.evmodel` 是稳定的单文件传输封装,记录原始格式并避免部署时扩展名猜测;它不会自动收集 OBJ/MTL 的外部 sidecar,因此离线生产优先以 GLB 或内嵌贴图 FBX 为输入。GPU Mesh 会保留所有导入 UV、顶点色及 tangent/bitangent 流供自定义管线和后续烘焙使用;内置 PBR shader 当前仍以 UV0 采样。 `createRenderable(gfx, modelData, meshIndex)` 内部等价于 C++ 的 `model3d::buildRenderable`(节点世界变换烘焙进顶点,材质 tint / metallic / roughness 与 diff --git a/docs/usr/modules/sceneloader.md b/docs/usr/modules/sceneloader.md index 5aada5bb..76504d84 100644 --- a/docs/usr/modules/sceneloader.md +++ b/docs/usr/modules/sceneloader.md @@ -29,11 +29,21 @@ if (host != null) { ### 大场景异步加载 ```squirrel -if (loader.pendingAsyncCount() == 0) loader.load("models/big.glb"); // 后台解码 +if (loader.pendingAsyncCount() == 0) loader.loadAsync("models/big.glb"); // 后台解码 // 主线程每帧轮询: loader.pollAsync(); // 就绪后挂载(GPU 上传在主线程) ``` +失败后用 `lastError(path)` 取得解码错误;成功后可用 `warningCount(path)` / `warning(path, i)` 检查缺失 normals、UV 等非致命问题。 + +### 导入 preset 与生产命名 + +`loadPreset(path, "quality|balanced|mobile|raw")` 和 `loadAsyncPreset(...)` 提供可重复的导入设置。默认 `quality/balanced` 保留完整内容;`mobile` 跳过灯光、相机和动画;`raw` 关闭会改写顶点布局的后处理。 + +- `Name_LOD0`、`Name_LOD1`:自动标为 LOD,初始只显示 LOD0;`setLod(path, level)` 切换。 +- `SOCKET_Weapon`:标为 `model-socket`,用 `socketCount/socketName` 枚举,也可通过 scene tag API 找到节点并挂接实体。 +- `UCX_`、`UBX_`、`USP_`、`UCP_`:标为隐藏的 `collision` 几何,不创建 Renderable;物理或编辑器流水线可按 tag 生成碰撞体。 + ## API 快查 ### `SceneLoader`(模块) @@ -44,9 +54,12 @@ loader.pollAsync(); // 就绪后挂载(GPU 上传在主线程) - `reloadChecked(path)` → bool:热重载并 diff 应用。 - `unload(path)`:卸载并销毁链接的 Renderable3D。 - `nodeCount(path)` / `loaded(path)`。 -- 异步:`pollAsync()`、`pendingAsyncCount()`、`prewarm(path)` / `clearPrewarm()` / +- 异步:`loadAsync(path)`、`loadAsyncPreset(path, preset)`、`pollAsync()`、`pendingAsyncCount()`、`prewarm(path)` / `clearPrewarm()` / `prewarmed(path)`。 +- preset/LOD/socket/collision:`loadPreset()`、`setLod()`、`socketCount()`、`socketName()`、`collisionCount()`、`collisionName()`。 +- 诊断:`lastError()`、`warningCount()`、`warning()`。 - 内容统计:`lightCount(path)`、`cameraCount(path)`、`animationCount(path)`。 +- 内容对象:`light(path, i)`、`camera(path, i)`、`skeleton(path)`、`clip(path, i)`。 ## 生命周期 diff --git a/src/modules/graphics/Mesh.cpp b/src/modules/graphics/Mesh.cpp index 28313033..dced4510 100644 --- a/src/modules/graphics/Mesh.cpp +++ b/src/modules/graphics/Mesh.cpp @@ -1,5 +1,7 @@ #include "graphics/Mesh.h" +#include + #include "graphics/Graphics.h" #include @@ -7,6 +9,59 @@ namespace eve::graphics { +namespace { +const std::vector kEmptyAttribute; +} + +void Mesh::captureImportedAttributes(const ::aiMesh &mesh) { + importedUvs_.clear(); + for (unsigned channel = 0; channel < mesh.GetNumUVChannels(); ++channel) { + if (!mesh.HasTextureCoords(channel)) continue; + std::vector values; + values.reserve(size_t(mesh.mNumVertices) * 2u); + for (unsigned i = 0; i < mesh.mNumVertices; ++i) { + values.push_back(mesh.mTextureCoords[channel][i].x); + values.push_back(mesh.mTextureCoords[channel][i].y); + } + importedUvs_.push_back(std::move(values)); + } + importedColors_.clear(); + for (unsigned channel = 0; channel < mesh.GetNumColorChannels(); ++channel) { + if (!mesh.HasVertexColors(channel)) continue; + std::vector values; + values.reserve(size_t(mesh.mNumVertices) * 4u); + for (unsigned i = 0; i < mesh.mNumVertices; ++i) { + const aiColor4D &c = mesh.mColors[channel][i]; + values.insert(values.end(), {c.r, c.g, c.b, c.a}); + } + importedColors_.push_back(std::move(values)); + } + importedTangents_.clear(); + importedBitangents_.clear(); + if (mesh.HasTangentsAndBitangents()) { + importedTangents_.reserve(size_t(mesh.mNumVertices) * 3u); + importedBitangents_.reserve(size_t(mesh.mNumVertices) * 3u); + for (unsigned i = 0; i < mesh.mNumVertices; ++i) { + const aiVector3D &t = mesh.mTangents[i]; + const aiVector3D &b = mesh.mBitangents[i]; + importedTangents_.insert(importedTangents_.end(), {t.x, t.y, t.z}); + importedBitangents_.insert(importedBitangents_.end(), {b.x, b.y, b.z}); + } + } +} + +const std::vector &Mesh::importedUv(int channel) const { + return channel >= 0 && static_cast(channel) < importedUvs_.size() + ? importedUvs_[static_cast(channel)] + : kEmptyAttribute; +} + +const std::vector &Mesh::importedColor(int channel) const { + return channel >= 0 && static_cast(channel) < importedColors_.size() + ? importedColors_[static_cast(channel)] + : kEmptyAttribute; +} + void Mesh::computeBounds(const float *posXYZ, int vertexCount) { boundsCx = 0.f; boundsCy = 0.f; diff --git a/src/modules/graphics/Mesh.h b/src/modules/graphics/Mesh.h index 9176bc09..455a594e 100644 --- a/src/modules/graphics/Mesh.h +++ b/src/modules/graphics/Mesh.h @@ -7,6 +7,8 @@ #include #include +struct aiMesh; + namespace eve::graphics { /** @@ -73,6 +75,16 @@ public: const std::vector &baseUv() const { return baseUv_; } + /** @brief Retain imported UV/color/tangent streams for custom pipelines and baking. */ + void captureImportedAttributes(const ::aiMesh &mesh); + int getUvChannelCount() const { return static_cast(importedUvs_.size()); } + int getColorChannelCount() const { return static_cast(importedColors_.size()); } + bool hasImportedTangents() const { return !importedTangents_.empty(); } + const std::vector &importedUv(int channel) const; + const std::vector &importedColor(int channel) const; + const std::vector &importedTangents() const { return importedTangents_; } + const std::vector &importedBitangents() const { return importedBitangents_; } + private: struct MorphTarget { std::string name; @@ -85,6 +97,10 @@ private: std::vector morphs_; std::unordered_map morphWeights_; bool morphDirty_ = false; + std::vector> importedUvs_; // xy packed per channel + std::vector> importedColors_; // rgba packed per channel + std::vector importedTangents_; // xyz packed + std::vector importedBitangents_; // xyz packed }; } // namespace eve::graphics diff --git a/src/modules/graphics/vulkan/GraphicsMesh.cpp b/src/modules/graphics/vulkan/GraphicsMesh.cpp index 4358aaf0..c0aa4575 100644 --- a/src/modules/graphics/vulkan/GraphicsMesh.cpp +++ b/src/modules/graphics/vulkan/GraphicsMesh.cpp @@ -105,6 +105,7 @@ Mesh *Graphics::newMeshFromAssimp(const ::aiMesh &mesh) { gpu = uploadGpuMesh(device, frameToken(), verts, indices); } auto handle = makeMeshHandle(*gpu); + handle->captureImportedAttributes(mesh); // Retain the CPU morph base pose only when the mesh actually has morphs; // otherwise the base pos/nrm/uv copies would linger at ~32B/vertex for no reason. if (mesh.mNumAnimMeshes > 0) { diff --git a/src/modules/graphics/webgpu/Graphics.cpp b/src/modules/graphics/webgpu/Graphics.cpp index 7abd1d9c..f9c01391 100644 --- a/src/modules/graphics/webgpu/Graphics.cpp +++ b/src/modules/graphics/webgpu/Graphics.cpp @@ -2153,6 +2153,7 @@ Mesh *Graphics::newMeshFromAssimp(const ::aiMesh &mesh, const aiMatrix4x4 &world } Mesh *m = newMeshFromArrays(pos.data(), nrm.data(), uv.data(), int(mesh.mNumVertices), idx.data(), int(idx.size())); + if (m) m->captureImportedAttributes(mesh); if (m && mesh.mNumAnimMeshes > 0) { m->initMorphBase(int(mesh.mNumVertices), pos.data(), nrm.data(), uv.data()); for (unsigned a = 0; a < mesh.mNumAnimMeshes; ++a) { diff --git a/src/modules/model3d/Model3D.cpp b/src/modules/model3d/Model3D.cpp index 65d169cd..55e123ba 100644 --- a/src/modules/model3d/Model3D.cpp +++ b/src/modules/model3d/Model3D.cpp @@ -18,7 +18,10 @@ #include #include +#include +#include #include +#include namespace eve { namespace model3d { @@ -64,6 +67,20 @@ medialoader::ModelScene loadOrThrow(medialoader::ModelLoader &loader, const void } } +constexpr char kEvModelMagic[4] = {'E', 'V', 'M', '1'}; + +bool unpackEvModel(const void *data, size_t size, const uint8_t **payload, size_t *payloadSize, + std::string *hint) { + if (!data || size < 6 || std::memcmp(data, kEvModelMagic, 4) != 0) return false; + const auto *bytes = static_cast(data); + const uint16_t n = uint16_t(bytes[4]) | (uint16_t(bytes[5]) << 8u); + if (n == 0 || size < size_t(6u + n)) return false; + hint->assign(reinterpret_cast(bytes + 6), n); + *payload = bytes + 6 + n; + *payloadSize = size - 6 - n; + return *payloadSize > 0; +} + } // namespace ModelData *Model3D::newModelData(Data *data, std::string hintExt) { @@ -81,6 +98,17 @@ ModelData *Model3D::newModelData(Data *data, std::string hintExt, hint = ensureDotExt(fd->getExtension()); } + const void *encoded = data->getData(); + size_t encodedSize = data->getSize(); + if (hint == ".evmodel") { + const uint8_t *payload = nullptr; + std::string packedHint; + if (!unpackEvModel(encoded, encodedSize, &payload, &encodedSize, &packedHint)) + throw eve::Exception("Invalid or truncated .evmodel envelope"); + encoded = payload; + hint = ensureDotExt(std::move(packedHint)); + } + // Prefer VFS for sidecar resolution when FileData carries a filename. filesystem::Filesystem *fs = ModuleManager::getInstance("Filesystem"); if (!fs) @@ -89,7 +117,7 @@ ModelData *Model3D::newModelData(Data *data, std::string hintExt, EveFileSystem eveFs(fs); medialoader::ModelLoader loader(&eveFs); - auto scene = loadOrThrow(loader, data->getData(), data->getSize(), hint.c_str(), + auto scene = loadOrThrow(loader, encoded, encodedSize, hint.c_str(), toMedialoader(options)); if (scene.empty()) throw eve::Exception("Could not decode model data"); @@ -127,6 +155,33 @@ graphics::Renderable3D *Model3D::createRenderable(graphics::Graphics *gfx, Model return buildRenderable(*gfx, model, meshIndex); } +bool Model3D::bakeModel(const std::string &sourcePath, const std::string &destinationPath) { + if (sourcePath.empty() || destinationPath.empty()) return false; + filesystem::Filesystem *fs = ModuleManager::getInstance("Filesystem"); + if (!fs) fs = filesystem::Filesystem::create(); + filesystem::FileData *sourceRaw = fs->read(sourcePath); + if (!sourceRaw) return false; + eve::ref source(sourceRaw); + if (!source->getData() || source->getSize() == 0) return false; + const std::string hint = ensureDotExt(source->getExtension()); + if (hint.empty() || hint == ".evmodel" || hint.size() > 65535u) return false; + std::vector packed; + packed.reserve(6u + hint.size() + source->getSize()); + packed.insert(packed.end(), kEvModelMagic, kEvModelMagic + 4); + const uint16_t n = static_cast(hint.size()); + packed.push_back(static_cast(n & 0xffu)); + packed.push_back(static_cast((n >> 8u) & 0xffu)); + packed.insert(packed.end(), hint.begin(), hint.end()); + const auto *bytes = static_cast(source->getData()); + packed.insert(packed.end(), bytes, bytes + source->getSize()); + try { + fs->write(destinationPath, packed.data(), static_cast(packed.size())); + return true; + } catch (...) { + return false; + } +} + void Model3D::expose(ssq::Table &table) { auto cls = table.addClass(name, Model3D::create, false); expose(cls); @@ -140,6 +195,15 @@ void Model3D::expose(ssq::Table &table) { md.addFunc("getFaceCount", &ModelData::getFaceCount); md.addFunc("hasNormals", &ModelData::hasNormals); md.addFunc("hasTexCoords", &ModelData::hasTexCoords); + md.addFunc("getTexCoordChannelCount", &ModelData::getTexCoordChannelCount); + md.addFunc("hasTexCoordChannel", &ModelData::hasTexCoordChannel); + md.addFunc("getTexCoord", &ModelData::getTexCoord); + md.addFunc("hasTangents", &ModelData::hasTangents); + md.addFunc("getTangent", &ModelData::getTangent); + md.addFunc("getBitangent", &ModelData::getBitangent); + md.addFunc("getVertexColorChannelCount", &ModelData::getVertexColorChannelCount); + md.addFunc("hasVertexColorChannel", &ModelData::hasVertexColorChannel); + md.addFunc("getVertexColor", &ModelData::getVertexColor); md.addFunc("getMaterialIndex", &ModelData::getMaterialIndex); md.addFunc("getMaterialName", &ModelData::getMaterialName); md.addFunc("getMaterialBaseColorR", &ModelData::getMaterialBaseColorR); @@ -180,6 +244,7 @@ void Model3D::expose(ssq::Class &cls) { cls.addFunc("newModelDataFromFile", static_cast(&Model3D::newModelDataFromFile)); cls.addFunc("createRenderable", &Model3D::createRenderable); + cls.addFunc("bakeModel", &Model3D::bakeModel); } } // namespace model3d diff --git a/src/modules/model3d/Model3D.h b/src/modules/model3d/Model3D.h index 084ca13d..9bdf76ce 100644 --- a/src/modules/model3d/Model3D.h +++ b/src/modules/model3d/Model3D.h @@ -73,6 +73,13 @@ public: */ graphics::Renderable3D *createRenderable(graphics::Graphics *gfx, ModelData *model, int meshIndex); + + /** + * @brief Pack a self-contained source model (normally GLB or embedded FBX) into + * EVEngine's deterministic .evmodel envelope for deployment. + * @return False when the source cannot be read or the destination cannot be written. + */ + bool bakeModel(const std::string &sourcePath, const std::string &destinationPath); }; } // namespace model3d diff --git a/src/modules/model3d/ModelData.cpp b/src/modules/model3d/ModelData.cpp index 7f0788a1..07d29cb7 100644 --- a/src/modules/model3d/ModelData.cpp +++ b/src/modules/model3d/ModelData.cpp @@ -122,6 +122,79 @@ bool ModelData::hasTexCoords(int meshIndex) const { return m->HasTextureCoords(0); } +int ModelData::getTexCoordChannelCount(int meshIndex) const { + const aiMesh *m = meshAt(meshIndex); + if (!m) throw eve::Exception("ModelData::getTexCoordChannelCount: invalid mesh index"); + return static_cast(m->GetNumUVChannels()); +} + +bool ModelData::hasTexCoordChannel(int meshIndex, int channel) const { + const aiMesh *m = meshAt(meshIndex); + if (!m) throw eve::Exception("ModelData::hasTexCoordChannel: invalid mesh index"); + return channel >= 0 && channel < AI_MAX_NUMBER_OF_TEXTURECOORDS && + m->HasTextureCoords(static_cast(channel)); +} + +float ModelData::getTexCoord(int meshIndex, int channel, int vertexIndex, int component) const { + const aiMesh *m = meshAt(meshIndex); + if (!m || channel < 0 || channel >= AI_MAX_NUMBER_OF_TEXTURECOORDS || vertexIndex < 0 || + static_cast(vertexIndex) >= m->mNumVertices || component < 0 || component > 2 || + !m->HasTextureCoords(static_cast(channel))) + throw eve::Exception("ModelData::getTexCoord: invalid mesh/channel/vertex/component"); + const aiVector3D &v = m->mTextureCoords[channel][vertexIndex]; + return component == 0 ? v.x : (component == 1 ? v.y : v.z); +} + +bool ModelData::hasTangents(int meshIndex) const { + const aiMesh *m = meshAt(meshIndex); + if (!m) throw eve::Exception("ModelData::hasTangents: invalid mesh index"); + return m->HasTangentsAndBitangents(); +} + +float ModelData::getTangent(int meshIndex, int vertexIndex, int component) const { + const aiMesh *m = meshAt(meshIndex); + if (!m || !m->HasTangentsAndBitangents() || vertexIndex < 0 || + static_cast(vertexIndex) >= m->mNumVertices || component < 0 || component > 2) + throw eve::Exception("ModelData::getTangent: invalid mesh/vertex/component"); + const aiVector3D &v = m->mTangents[vertexIndex]; + return component == 0 ? v.x : (component == 1 ? v.y : v.z); +} + +float ModelData::getBitangent(int meshIndex, int vertexIndex, int component) const { + const aiMesh *m = meshAt(meshIndex); + if (!m || !m->HasTangentsAndBitangents() || vertexIndex < 0 || + static_cast(vertexIndex) >= m->mNumVertices || component < 0 || component > 2) + throw eve::Exception("ModelData::getBitangent: invalid mesh/vertex/component"); + const aiVector3D &v = m->mBitangents[vertexIndex]; + return component == 0 ? v.x : (component == 1 ? v.y : v.z); +} + +int ModelData::getVertexColorChannelCount(int meshIndex) const { + const aiMesh *m = meshAt(meshIndex); + if (!m) throw eve::Exception("ModelData::getVertexColorChannelCount: invalid mesh index"); + return static_cast(m->GetNumColorChannels()); +} + +bool ModelData::hasVertexColorChannel(int meshIndex, int channel) const { + const aiMesh *m = meshAt(meshIndex); + if (!m) throw eve::Exception("ModelData::hasVertexColorChannel: invalid mesh index"); + return channel >= 0 && channel < AI_MAX_NUMBER_OF_COLOR_SETS && + m->HasVertexColors(static_cast(channel)); +} + +float ModelData::getVertexColor(int meshIndex, int channel, int vertexIndex, int component) const { + const aiMesh *m = meshAt(meshIndex); + if (!m || channel < 0 || channel >= AI_MAX_NUMBER_OF_COLOR_SETS || vertexIndex < 0 || + static_cast(vertexIndex) >= m->mNumVertices || component < 0 || component > 3 || + !m->HasVertexColors(static_cast(channel))) + throw eve::Exception("ModelData::getVertexColor: invalid mesh/channel/vertex/component"); + const aiColor4D &v = m->mColors[channel][vertexIndex]; + if (component == 0) return v.r; + if (component == 1) return v.g; + if (component == 2) return v.b; + return v.a; +} + int ModelData::getMaterialIndex(int meshIndex) const { const aiMesh *m = meshAt(meshIndex); return m ? static_cast(m->mMaterialIndex) : -1; diff --git a/src/modules/model3d/ModelData.h b/src/modules/model3d/ModelData.h index 18a4deb9..cd382634 100644 --- a/src/modules/model3d/ModelData.h +++ b/src/modules/model3d/ModelData.h @@ -36,6 +36,24 @@ public: bool hasNormals(int meshIndex) const; /** @throws eve::Exception when meshIndex is out of range. */ bool hasTexCoords(int meshIndex) const; + /** @brief Number of populated UV channels (0..AI_MAX_NUMBER_OF_TEXTURECOORDS). */ + int getTexCoordChannelCount(int meshIndex) const; + /** @brief Whether a mesh has the requested UV channel. */ + bool hasTexCoordChannel(int meshIndex, int channel) const; + /** @brief UV component in channel for vertex (component 0=u, 1=v, 2=w). */ + float getTexCoord(int meshIndex, int channel, int vertexIndex, int component) const; + /** @brief Whether imported tangent and bitangent streams exist. */ + bool hasTangents(int meshIndex) const; + /** @brief Tangent XYZ component for a vertex. */ + float getTangent(int meshIndex, int vertexIndex, int component) const; + /** @brief Bitangent XYZ component for a vertex. */ + float getBitangent(int meshIndex, int vertexIndex, int component) const; + /** @brief Number of populated vertex-color channels. */ + int getVertexColorChannelCount(int meshIndex) const; + /** @brief Whether a mesh has the requested vertex-color channel. */ + bool hasVertexColorChannel(int meshIndex, int channel) const; + /** @brief RGBA component (0..3) in a vertex-color channel. */ + float getVertexColor(int meshIndex, int channel, int vertexIndex, int component) const; // ---- material accessors ---- /** @brief Assimp material slot referenced by a mesh; -1 when invalid. */ diff --git a/src/modules/model3d/ModelLoader.cpp b/src/modules/model3d/ModelLoader.cpp index 9c3ef2b9..8a38e297 100644 --- a/src/modules/model3d/ModelLoader.cpp +++ b/src/modules/model3d/ModelLoader.cpp @@ -12,6 +12,7 @@ #include "common/Module.h" #include "common/Resource.h" #include "filesystem/Filesystem.h" +#include "filesystem/FileData.h" #include "medialoader/Exception.h" #include "medialoader/model/ModelLoader.h" #include "model3d/EveFileSystem.h" @@ -41,7 +42,7 @@ bool isModelPath(const std::string &path) { ext == ".b3d" || ext == ".csm" || ext == ".irr" || ext == ".irrmesh" || ext == ".md2" || ext == ".md3" || ext == ".ms3d" || ext == ".smd" || ext == ".vta" || ext == ".bvh" || ext == ".ac" || ext == ".off" || ext == ".raw" || ext == ".ter" || ext == ".nff" || - ext == ".ndo"; + ext == ".ndo" || ext == ".evmodel"; } // Mirror of the option mapping in Model3D.cpp. @@ -106,6 +107,15 @@ public: if (!fs) fs = filesystem::Filesystem::create(); if (!fs) return nullptr; + if (extensionOf(path) == ".evmodel") { + filesystem::FileData *packedRaw = fs->read(path); + if (!packedRaw) return nullptr; + eve::ref packed(packedRaw); + Model3D *module = ModuleManager::getInstance("Model3D"); + if (!module) module = Model3D::create(); + return module->newModelData(packed.get(), ".evmodel", options); + } + EveFileSystem eveFs(fs); medialoader::ModelLoader loader(&eveFs); try { diff --git a/src/modules/sceneloader/SceneLoader.cpp b/src/modules/sceneloader/SceneLoader.cpp index af45a9e1..18c3b6f6 100644 --- a/src/modules/sceneloader/SceneLoader.cpp +++ b/src/modules/sceneloader/SceneLoader.cpp @@ -125,6 +125,48 @@ std::string uniqueId(const std::string &base, std::unordered_map::length(prefix); + if (value.size() < n) return false; + for (size_t i = 0; i < n; ++i) + if (std::tolower(static_cast(value[i])) != + std::tolower(static_cast(prefix[i]))) + return false; + return true; +} + +int lodFromName(const std::string &name) { + const size_t p = name.rfind("_LOD"); + if (p == std::string::npos || p + 4 >= name.size()) return -1; + char *end = nullptr; + const long value = std::strtol(name.c_str() + p + 4, &end, 10); + return end == name.c_str() + p + 4 || value < 0 ? -1 : static_cast(value); +} + +LoadOptions presetOptions(const std::string &name) { + LoadOptions o; + if (name == "quality" || name == "balanced" || name.empty()) return o; + if (name == "mobile") { + o.importLights = false; + o.importCameras = false; + o.importAnimations = false; + return o; + } + if (name == "raw") { + o.generateNormalsIfMissing = false; + o.joinIdenticalVertices = false; + o.flipUVs = false; + o.improveCacheLocality = false; + o.sharedMeshes = false; + o.mipmaps = false; + o.importLights = false; + o.importCameras = false; + o.importAnimations = false; + return o; + } + throw eve::Exception("SceneLoader: unknown import preset '%s'", name.c_str()); +} + // ---- texture helpers (embedded / VFS, cached by path) ---- /** Texture sampling derived from the source material (wrap + glTF mag/min filters). */ @@ -466,6 +508,18 @@ void buildNodeRecursive(const aiScene *scene, const aiNode *node, scene::NodeDes out.name = rawName; out.space = "3d"; out.visible = true; + if (startsWithInsensitive(rawName, "SOCKET_")) out.tags.push_back("model-socket"); + if (startsWithInsensitive(rawName, "UCX_") || startsWithInsensitive(rawName, "UBX_") || + startsWithInsensitive(rawName, "USP_") || startsWithInsensitive(rawName, "UCP_")) { + out.tags.push_back("collision"); + out.visible = false; + } + const int lod = lodFromName(rawName); + if (lod >= 0) { + out.tags.push_back("lod"); + out.tags.push_back("lod:" + std::to_string(lod)); + out.visible = lod == 0; + } decomposeNode(node->mTransformation, out.x, out.y, out.z, out.yaw, out.pitch, out.roll, out.sx, out.sy, out.sz); @@ -481,7 +535,8 @@ void buildNodeRecursive(const aiScene *scene, const aiNode *node, scene::NodeDes child.key = cid; child.name = std::string("mesh") + std::to_string(i); child.space = "3d"; - child.visible = true; + child.visible = out.visible; + child.tags = out.tags; out.children.push_back(std::move(child)); if (slots) { @@ -728,6 +783,7 @@ bool SceneLoader::applyTreeDiff(scene::SceneHost *host, const scene::NodeDesc &n TextureCache unused; for (auto &n : nodes) { if (!n.links.empty()) continue; + if (std::find(n.tags.begin(), n.tags.end(), "collision") != n.tags.end()) continue; auto sit = slots->find(n.id); if (sit == slots->end() || sit->second.empty()) continue; graphics::Renderable3D *r = nullptr; @@ -772,6 +828,7 @@ void SceneLoader::linkMeshNodes(scene::SceneHost *host, const MeshSlotMap &slots for (const auto &kv : slots) { scene::SceneNode *n = host->findById(kv.first); if (!n || kv.second.empty()) continue; + if (std::find(n->tags.begin(), n->tags.end(), "collision") != n->tags.end()) continue; if (host->findLink(n, scene::findLinkKind("renderable3d"))) continue; const MeshSlot &slot = kv.second[0]; graphics::Mesh *mesh = nullptr; @@ -824,6 +881,7 @@ void SceneLoader::linkMeshNodes(scene::SceneHost *host, const MeshSlotMap &slots } bool SceneLoader::decode(const std::string &path, const LoadOptions &options, DecodedScene *out) { + const std::string key = normPath(path); // The unified resource cache may hold a decode from an earlier load; a // reload/diff must see the file as it is now, so refresh the cached entry // before asking for it (no-op when nothing is cached yet). @@ -834,10 +892,33 @@ bool SceneLoader::decode(const std::string &path, const LoadOptions &options, De model3d::ModelData *md = nullptr; try { md = mod3d->newModelDataFromFile(path, toModelOptions(options)); + } catch (const std::exception &e) { + std::lock_guard lock(pendingMu_); + lastErrors_[key] = e.what(); + return false; } catch (...) { + std::lock_guard lock(pendingMu_); + lastErrors_[key] = "unknown model decode error"; return false; } - if (!md) return false; + if (!md) { + std::lock_guard lock(pendingMu_); + lastErrors_[key] = "model decoder returned no scene"; + return false; + } + + std::vector importWarnings; + for (int i = 0; i < md->getMeshCount(); ++i) { + if (!md->hasNormals(i)) + importWarnings.push_back("mesh " + std::to_string(i) + " has no normals"); + if (!md->hasTexCoords(i)) + importWarnings.push_back("mesh " + std::to_string(i) + " has no UV0"); + } + { + std::lock_guard lock(pendingMu_); + lastErrors_.erase(key); + warnings_[key] = std::move(importWarnings); + } out->path = normPath(path); out->md = md; @@ -901,6 +982,10 @@ scene::SceneHost *SceneLoader::load(const std::string &path, const LoadOptions & return load(path, true, options); } +scene::SceneHost *SceneLoader::loadPreset(const std::string &path, const std::string &preset) { + return load(path, presetOptions(preset)); +} + bool SceneLoader::reload(const std::string &path, SceneDiff *out, const LoadOptions &options) { const std::string key = normPath(path); auto it = scenes_.find(key); @@ -995,8 +1080,8 @@ bool SceneLoader::loadAsync(const std::string &path, const LoadOptions &options, const std::string key = normPath(path); { std::lock_guard lock(pendingMu_); - for (const auto &p : pending_) - if (p.path == key) return false; + if (inFlight_.count(key)) return false; + inFlight_.insert(key); } if (!pool_) pool_ = std::make_shared(2); @@ -1008,10 +1093,16 @@ bool SceneLoader::loadAsync(const std::string &path, const LoadOptions &options, d.done = done; self->pending_.push_back(std::move(d)); } + std::lock_guard lock(self->pendingMu_); + self->inFlight_.erase(key); }); return true; } +bool SceneLoader::loadAsyncPreset(const std::string &path, const std::string &preset) { + return loadAsync(path, presetOptions(preset)); +} + int SceneLoader::pollAsync() { std::vector ready; { @@ -1034,19 +1125,21 @@ bool SceneLoader::prewarmAsync(const std::string &path, const LoadOptions &optio const std::string key = normPath(path); { std::lock_guard lock(pendingMu_); - if (prewarmed_.count(key)) return false; - for (const auto &p : pending_) - if (p.path == key) return false; + if (prewarmed_.count(key) || inFlight_.count(key)) return false; + inFlight_.insert(key); } if (!pool_) pool_ = std::make_shared(2); auto self = this; pool_->submit([self, key, options]() { DecodedScene d; - if (!self->decode(key, options, &d)) return; - d.prewarmOnly = true; + if (self->decode(key, options, &d)) { + d.prewarmOnly = true; + std::lock_guard lock(self->pendingMu_); + self->pending_.push_back(std::move(d)); + } std::lock_guard lock(self->pendingMu_); - self->pending_.push_back(std::move(d)); + self->inFlight_.erase(key); }); return true; } @@ -1063,7 +1156,72 @@ void SceneLoader::clearPrewarm(const std::string &path) { int SceneLoader::pendingAsyncCount() const { std::lock_guard lock(pendingMu_); - return static_cast(pending_.size()); + return static_cast(pending_.size() + inFlight_.size()); +} + +std::string SceneLoader::lastError(const std::string &path) const { + std::lock_guard lock(pendingMu_); + auto it = lastErrors_.find(normPath(path)); + return it == lastErrors_.end() ? std::string{} : it->second; +} + +int SceneLoader::warningCount(const std::string &path) const { + std::lock_guard lock(pendingMu_); + auto it = warnings_.find(normPath(path)); + return it == warnings_.end() ? 0 : static_cast(it->second.size()); +} + +std::string SceneLoader::warning(const std::string &path, int index) const { + std::lock_guard lock(pendingMu_); + auto it = warnings_.find(normPath(path)); + if (it == warnings_.end() || index < 0 || static_cast(index) >= it->second.size()) + return {}; + return it->second[static_cast(index)]; +} + +bool SceneLoader::setLod(const std::string &path, int level) { + scene::SceneHost *h = host(path); + if (!h || level < 0) return false; + bool found = false; + auto tree = h->tree(); + for (scene::SceneNode &node : tree->nodes) { + int nodeLod = -1; + for (const std::string &tag : node.tags) + if (tag.rfind("lod:", 0) == 0) nodeLod = std::atoi(tag.c_str() + 4); + if (nodeLod >= 0) { + node.visible = nodeLod == level; + found = found || node.visible; + } + } + return found; +} + +int SceneLoader::socketCount(const std::string &path) const { + auto it = scenes_.find(normPath(path)); + if (it == scenes_.end() || !it->second.host) return 0; + return static_cast(it->second.host->findAllByTag("model-socket").size()); +} + +std::string SceneLoader::socketName(const std::string &path, int index) const { + auto it = scenes_.find(normPath(path)); + if (it == scenes_.end() || !it->second.host || index < 0) return {}; + auto sockets = it->second.host->findAllByTag("model-socket"); + return static_cast(index) < sockets.size() ? sockets[static_cast(index)]->name + : std::string{}; +} + +int SceneLoader::collisionCount(const std::string &path) const { + auto it = scenes_.find(normPath(path)); + if (it == scenes_.end() || !it->second.host) return 0; + return static_cast(it->second.host->findAllByTag("collision").size()); +} + +std::string SceneLoader::collisionName(const std::string &path, int index) const { + auto it = scenes_.find(normPath(path)); + if (it == scenes_.end() || !it->second.host || index < 0) return {}; + auto nodes = it->second.host->findAllByTag("collision"); + return static_cast(index) < nodes.size() ? nodes[static_cast(index)]->name + : std::string{}; } // ---- imported scene extras ---- @@ -1124,18 +1282,34 @@ void SceneLoader::expose(ssq::Class &cls) { [](SceneLoader *sl, const std::string &path) -> scene::SceneHost * { return sl ? sl->load(path) : nullptr; })); + cls.addFunc("loadPreset", &SceneLoader::loadPreset); cls.addFunc("host", &SceneLoader::host); cls.addFunc("reloadChecked", &SceneLoader::reloadChecked); cls.addFunc("nodeCount", &SceneLoader::nodeCount); cls.addFunc("loaded", &SceneLoader::loaded); cls.addFunc("unload", &SceneLoader::unload); cls.addFunc("pollAsync", &SceneLoader::pollAsync); + cls.addFunc("loadAsync", &SceneLoader::loadAsyncDefault); + cls.addFunc("loadAsyncPreset", &SceneLoader::loadAsyncPreset); cls.addFunc("pendingAsyncCount", &SceneLoader::pendingAsyncCount); cls.addFunc("prewarmed", &SceneLoader::prewarmed); cls.addFunc("clearPrewarm", &SceneLoader::clearPrewarm); + cls.addFunc("prewarm", &SceneLoader::prewarm); + cls.addFunc("lastError", &SceneLoader::lastError); + cls.addFunc("warningCount", &SceneLoader::warningCount); + cls.addFunc("warning", &SceneLoader::warning); + cls.addFunc("setLod", &SceneLoader::setLod); + cls.addFunc("socketCount", &SceneLoader::socketCount); + cls.addFunc("socketName", &SceneLoader::socketName); + cls.addFunc("collisionCount", &SceneLoader::collisionCount); + cls.addFunc("collisionName", &SceneLoader::collisionName); cls.addFunc("lightCount", &SceneLoader::lightCount); cls.addFunc("cameraCount", &SceneLoader::cameraCount); cls.addFunc("animationCount", &SceneLoader::animationCount); + cls.addFunc("light", &SceneLoader::light); + cls.addFunc("camera", &SceneLoader::camera); + cls.addFunc("skeleton", &SceneLoader::skeleton); + cls.addFunc("clip", &SceneLoader::clip); } } // namespace sceneloader diff --git a/src/modules/sceneloader/SceneLoader.h b/src/modules/sceneloader/SceneLoader.h index 030f24d5..59c5c378 100644 --- a/src/modules/sceneloader/SceneLoader.h +++ b/src/modules/sceneloader/SceneLoader.h @@ -7,6 +7,7 @@ #include #include #include +#include #include struct aiScene; @@ -143,6 +144,8 @@ public: scene::SceneHost *load(const std::string &path, bool linkRenderables = true, const LoadOptions &options = {}); scene::SceneHost *load(const std::string &path, const LoadOptions &options); + /** @brief Load with a built-in preset: quality, balanced, mobile, or raw. */ + scene::SceneHost *loadPreset(const std::string &path, const std::string &preset); /** * @brief Hot-reload `path`. Re-decodes, diffs, and applies only what changed. @@ -180,6 +183,10 @@ public: bool loadAsync(const std::string &path, const LoadOptions &options = {}, std::function done = nullptr); + /** @brief Script-friendly async load using the default import preset. */ + bool loadAsyncDefault(const std::string &path) { return loadAsync(path); } + bool loadAsyncPreset(const std::string &path, const std::string &preset); + /** * @brief Mount every decoded-but-not-applied scene. Must be called on the main / * render thread. Returns the number of scenes applied this call. @@ -188,6 +195,8 @@ public: /** @brief Decode and retain a scene without creating ECS/GPU objects. */ bool prewarmAsync(const std::string &path, const LoadOptions &options = {}); + /** @brief Script-friendly CPU prewarm using the default import preset. */ + bool prewarm(const std::string &path) { return prewarmAsync(path); } /** @brief True when a decoded scene is ready for load() to consume. */ bool prewarmed(const std::string &path) const; /** @brief Drop a decoded prewarm result and release its CPU scene. */ @@ -196,6 +205,23 @@ public: /** @brief Number of async loads still waiting to be mounted. */ int pendingAsyncCount() const; + /** @brief Last decode/mount error for path, cleared by the next successful decode. */ + std::string lastError(const std::string &path) const; + /** @brief Number of non-fatal import warnings retained for path. */ + int warningCount(const std::string &path) const; + /** @brief Non-fatal import warning at index, or an empty string. */ + std::string warning(const std::string &path, int index) const; + /** @brief Select imported name-convention LOD (_LOD0, _LOD1, ...). */ + bool setLod(const std::string &path, int level); + /** @brief Number of imported SOCKET_ nodes. */ + int socketCount(const std::string &path) const; + /** @brief Name of an imported SOCKET_ node, or an empty string. */ + std::string socketName(const std::string &path, int index) const; + /** @brief Number of imported UCX_/UBX_/USP_/UCP_ collision nodes. */ + int collisionCount(const std::string &path) const; + /** @brief Name of an imported collision node, or an empty string. */ + std::string collisionName(const std::string &path, int index) const; + // ---- imported scene extras ---- /** @brief Number of imported Light3D entities for `path`; 0 if none / disabled. */ @@ -296,6 +322,9 @@ private: TextureCache textures_; std::shared_ptr pool_; std::vector pending_; + std::unordered_set inFlight_; + std::unordered_map lastErrors_; + std::unordered_map> warnings_; mutable std::mutex pendingMu_; }; diff --git a/test/model3d.cpp b/test/model3d.cpp index e415f55f..93bbd9e2 100644 --- a/test/model3d.cpp +++ b/test/model3d.cpp @@ -573,6 +573,9 @@ TEST_CASE("model3d.newModelData.objFromMemory") { // `vn` lines, but never synthesizes UVs without `vt` lines. CHECK(md->hasNormals(0)); CHECK(!md->hasTexCoords(0)); + CHECK_EQ(md->getTexCoordChannelCount(0), 0); + CHECK_EQ(md->getVertexColorChannelCount(0), 0); + CHECK(!md->hasTangents(0)); delete md; } @@ -655,6 +658,21 @@ TEST_CASE("model3d.Model3D.getName") { CHECK(mod->getName() == "Model3D"); } +TEST_CASE("model3d.evmodel.roundTrip") { + auto *fs = eve::filesystem::Filesystem::create(); + REQUIRE(fs->setIdentity("ev_ut_model3d_evmodel", true)); + REQUIRE(fs->setupWriteDirectory()); + fs->write("source.obj", kCubeObj, sizeof(kCubeObj) - 1); + + auto *mod = eve::model3d::Model3D::create(); + REQUIRE(mod->bakeModel("source.obj", "cube.evmodel")); + auto *md = mod->newModelDataFromFile("cube.evmodel"); + REQUIRE(md != nullptr); + CHECK(md->getMeshCount() >= 1); + CHECK(md->getFaceCount(0) > 0); + delete md; +} + TEST_CASE("model3d.newModelData.rock1Fbx") { const std::string path = pathBesideThisSource("Rock1.fbx"); auto bytes = readBinaryFile(path); diff --git a/test/sceneloader.cpp b/test/sceneloader.cpp index 8c3323d8..c34a4fde 100644 --- a/test/sceneloader.cpp +++ b/test/sceneloader.cpp @@ -40,6 +40,7 @@ #include +#include #include #include #include @@ -230,6 +231,32 @@ TEST_CASE("SceneLoader.apply.preservesUnchangedRenderableIdentity") { ecs::DestroyEntity(r); } +TEST_CASE("SceneLoader.importConventions.lodCollisionSocket") { + aiScene scene{}; + scene.mRootNode = new aiNode("Root"); + scene.mRootNode->mNumChildren = 4; + scene.mRootNode->mChildren = new aiNode *[4]; + scene.mRootNode->mChildren[0] = new aiNode("Building_LOD0"); + scene.mRootNode->mChildren[1] = new aiNode("Building_LOD1"); + scene.mRootNode->mChildren[2] = new aiNode("UCX_Building"); + scene.mRootNode->mChildren[3] = new aiNode("SOCKET_Door"); + for (unsigned i = 0; i < 4; ++i) scene.mRootNode->mChildren[i]->mParent = scene.mRootNode; + + NodeDesc root = SceneLoader::buildNodeDesc(&scene); + REQUIRE_EQ(root.children.size(), 4u); + CHECK(root.children[0].visible); + CHECK(!root.children[1].visible); + CHECK(!root.children[2].visible); + CHECK(std::find(root.children[0].tags.begin(), root.children[0].tags.end(), "lod:0") != + root.children[0].tags.end()); + CHECK(std::find(root.children[1].tags.begin(), root.children[1].tags.end(), "lod:1") != + root.children[1].tags.end()); + CHECK(std::find(root.children[2].tags.begin(), root.children[2].tags.end(), "collision") != + root.children[2].tags.end()); + CHECK(std::find(root.children[3].tags.begin(), root.children[3].tags.end(), "model-socket") != + root.children[3].tags.end()); +} + // ---- integration: decode a real 3D scene into ECS GameObjects + hot reload ---- static const char kCubeObj[] =