#include "zeroerr/assert.h" #include "zeroerr/unittest.h" #include "Fixtures.h" #include "common/Exception.h" #include "data/ByteData.h" #include "filesystem/FileData.h" #include "filesystem/Filesystem.h" #include "model3d/Model3D.h" #include "model3d/ModelData.h" #include "model3d/ModelRenderer.h" #include "graphics/AmbientOcclusion.h" #include "graphics/AntiAliasing.h" #include "graphics/Canvas.h" #include "graphics/DrawItem2D.h" #include "graphics/Font.h" #include "graphics/GBuffer.h" #include "graphics/GlobalIllumination.h" #include "graphics/Graphics.h" #include "graphics/Grass.h" #include "graphics/Light.h" #include "graphics/Material.h" #include "graphics/Outline.h" #include "graphics/Quad.h" #include "graphics/RenderControl.h" #include "graphics/ScreenSpaceReflection.h" #include "graphics/Shader.h" #include "graphics/Texture.h" #include "graphics/Volumetric.h" #include "graphics/Water.h" #include "graphics/Waterfall.h" // Color lives in eve::graphics (see graphics/Canvas.h); keep the unqualified form. using eve::graphics::Color; #include "graphics/Mesh.h" #include "graphics/RenderSystem.h" #include "graphics/RenderSystem3D.h" #include "image/Image.h" #include "image/ImageData.h" #include "window/Window.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "graphics/shaders/mesh3d_toon_frag_spv.inc" #include "graphics/shaders/mesh3d_toon_vert_spv.inc" using namespace eve::graphics; namespace { #include "PathBesideSource.h" EVE_DEFINE_PATH_BESIDE_SOURCE() std::vector readBinaryFile(const std::string &path) { std::ifstream in(path, std::ios::binary); if (!in) return {}; return std::vector((std::istreambuf_iterator(in)), std::istreambuf_iterator()); } std::string base64Encode(const uint8_t *data, size_t size) { static const char tbl[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; std::string out; out.reserve(((size + 2) / 3) * 4); for (size_t i = 0; i < size; i += 3) { const uint32_t a = data[i]; const uint32_t b = i + 1 < size ? data[i + 1] : 0; const uint32_t c = i + 2 < size ? data[i + 2] : 0; const uint32_t v = (a << 16) | (b << 8) | c; out += tbl[(v >> 18) & 63]; out += tbl[(v >> 12) & 63]; out += (i + 1 < size) ? tbl[(v >> 6) & 63] : '='; out += (i + 2 < size) ? tbl[v & 63] : '='; } return out; } void appendF32LE(std::vector &bin, float v) { uint32_t bits = 0; std::memcpy(&bits, &v, 4); bin.push_back(uint8_t(bits)); bin.push_back(uint8_t(bits >> 8)); bin.push_back(uint8_t(bits >> 16)); bin.push_back(uint8_t(bits >> 24)); } void appendU16LE(std::vector &bin, uint16_t v) { bin.push_back(uint8_t(v)); bin.push_back(uint8_t(v >> 8)); } /** * Minimal glTF 2.0 quad (XY plane facing +Z) with an embedded PNG base color * texture: top half red, bottom half blue. Y=+1 maps to v=0 so a correctly * oriented render shows red on top / blue on bottom. */ std::string makeUvOrientationGltf(const std::string &pngBase64) { std::vector bin; const float pos[4][3] = {{-1.f, -1.f, 0.f}, {1.f, -1.f, 0.f}, {-1.f, 1.f, 0.f}, {1.f, 1.f, 0.f}}; const float nrm[4][3] = {{0.f, 0.f, 1.f}, {0.f, 0.f, 1.f}, {0.f, 0.f, 1.f}, {0.f, 0.f, 1.f}}; const float uv[4][2] = {{0.f, 1.f}, {1.f, 1.f}, {0.f, 0.f}, {1.f, 0.f}}; for (const auto &p : pos) for (float c : p) appendF32LE(bin, c); for (const auto &n : nrm) for (float c : n) appendF32LE(bin, c); for (const auto &t : uv) for (float c : t) appendF32LE(bin, c); const uint16_t idx[6] = {0, 1, 2, 2, 1, 3}; for (uint16_t i : idx) appendU16LE(bin, i); if (bin.size() != 140) return {}; const std::string binBase64 = base64Encode(bin.data(), bin.size()); return std::string( "{\"asset\":{\"version\":\"2.0\"},\"scene\":0,\"scenes\":[{\"nodes\":[0]}]," "\"nodes\":[{\"mesh\":0}]," "\"meshes\":[{\"primitives\":[{\"attributes\":{\"POSITION\":0,\"NORMAL\":1," "\"TEXCOORD_0\":2},\"indices\":3,\"material\":0}]}]," "\"materials\":[{\"pbrMetallicRoughness\":{\"baseColorTexture\":{\"index\":0}," "\"metallicFactor\":0.0,\"roughnessFactor\":1.0}}]," "\"textures\":[{\"source\":0}]," "\"images\":[{\"uri\":\"data:image/png;base64,") + pngBase64 + "\"}],\"buffers\":[{\"uri\":\"data:application/octet-stream;base64," + binBase64 + "\",\"byteLength\":140}],\"bufferViews\":[" + "{\"buffer\":0,\"byteOffset\":0,\"byteLength\":48,\"target\":34962}," + "{\"buffer\":0,\"byteOffset\":48,\"byteLength\":48,\"target\":34962}," + "{\"buffer\":0,\"byteOffset\":96,\"byteLength\":32,\"target\":34962}," + "{\"buffer\":0,\"byteOffset\":128,\"byteLength\":12,\"target\":34963}]," "\"accessors\":[" + "{\"bufferView\":0,\"componentType\":5126,\"count\":4,\"type\":\"VEC3\"}," + "{\"bufferView\":1,\"componentType\":5126,\"count\":4,\"type\":\"VEC3\"}," + "{\"bufferView\":2,\"componentType\":5126,\"count\":4,\"type\":\"VEC2\"}," + "{\"bufferView\":3,\"componentType\":5123,\"count\":6,\"type\":\"SCALAR\"}]}"; } float luma(const Color &c) { return (c.r + c.g + c.b) / 3.f; } void resetScene3D() { if (ecs::current()->getManager() != nullptr) { auto view = ecs::View(); for (auto it = view.begin(); it != view.end(); ++it) { auto [mr] = *it; mr->visible = false; } } if (ecs::current()->getManager() != nullptr) { auto camView = ecs::View(); for (auto it = camView.begin(); it != camView.end(); ++it) { auto [data] = *it; data->active = false; } } if (ecs::current()->getManager() != nullptr) { auto view = ecs::View(); for (auto it = view.begin(); it != view.end(); ++it) { auto [sp] = *it; sp->visible = false; } } } Texture *makeSolidGray(Graphics *gfx, uint8_t g) { uint8_t px[4] = {g, g, g, 255}; return gfx->newTexture(1, 1, px); } Texture *makeSolidRGB(Graphics *gfx, uint8_t r, uint8_t g, uint8_t b) { uint8_t px[4] = {r, g, b, 255}; return gfx->newTexture(1, 1, px); } Texture *textureFromImageData(Graphics *gfx, eve::image::ImageData *img) { if (!img) return nullptr; return gfx->newTexture(img); } Texture *loadAssimpDiffuseTexture(Graphics *gfx, const aiScene *scene, const aiMaterial *mat) { if (!scene || !mat) return nullptr; aiString path; if (mat->GetTexture(aiTextureType_DIFFUSE, 0, &path) != AI_SUCCESS) return nullptr; const char *p = path.C_Str(); if (!p || !p[0]) return nullptr; [[maybe_unused]] auto *const imageModule = eve::image::Image::create(); // Embedded: "*0", "*1", ... if (p[0] == '*') { int idx = std::atoi(p + 1); if (idx < 0 || static_cast(idx) >= scene->mNumTextures) return nullptr; const aiTexture *tex = scene->mTextures[idx]; if (!tex || !tex->pcData) return nullptr; if (tex->mHeight == 0) { // Compressed blob (png/jpg/...). eve::data::ByteData bytes(tex->pcData, static_cast(tex->mWidth)); try { eve::image::ImageData *img = eve::image::Image::create()->newImageData(&bytes); Texture *t = textureFromImageData(gfx, img); delete img; return t; } catch (...) { return nullptr; } } // Uncompressed BGRA8888. const unsigned w = tex->mWidth; const unsigned h = tex->mHeight; std::vector rgba(size_t(w) * size_t(h) * 4); const aiTexel *src = tex->pcData; for (unsigned i = 0; i < w * h; ++i) { rgba[i * 4 + 0] = src[i].r; rgba[i * 4 + 1] = src[i].g; rgba[i * 4 + 2] = src[i].b; rgba[i * 4 + 3] = src[i].a; } return gfx->newTexture(int(w), int(h), rgba.data()); } // External file — try VFS then OS path beside test assets. try { auto *fs = eve::filesystem::Filesystem::create(); std::unique_ptr fd(fs->read(p)); if (fd && fd->getSize() > 0) { eve::image::ImageData *img = eve::image::Image::create()->newImageData(fd.get()); Texture *t = textureFromImageData(gfx, img); delete img; return t; } } catch (...) { } // Basename / common relative layouts under test/ std::string base = p; auto slash = base.find_last_of("/\\"); if (slash != std::string::npos) base = base.substr(slash + 1); const char *candidates[] = { base.c_str(), nullptr, // filled below for Textures/ }; std::string underTextures = std::string("Textures/") + base; candidates[1] = underTextures.c_str(); for (const char *rel : candidates) { if (!rel || !rel[0]) continue; auto fileBytes = readBinaryFile(pathBesideThisSource(rel)); if (fileBytes.empty()) continue; try { eve::data::ByteData bytes(fileBytes.data(), fileBytes.size()); eve::image::ImageData *img = eve::image::Image::create()->newImageData(&bytes); Texture *t = textureFromImageData(gfx, img); delete img; return t; } catch (...) { } } return nullptr; } Texture *loadTextureFile(Graphics *gfx, const char *relPathBesideTest) { if (!relPathBesideTest || !relPathBesideTest[0]) return nullptr; auto fileBytes = readBinaryFile(pathBesideThisSource(relPathBesideTest)); if (fileBytes.empty()) return nullptr; [[maybe_unused]] auto *const imageModule = eve::image::Image::create(); try { eve::data::ByteData bytes(fileBytes.data(), fileBytes.size()); eve::image::ImageData *img = eve::image::Image::create()->newImageData(&bytes); Texture *t = textureFromImageData(gfx, img); delete img; return t; } catch (...) { return nullptr; } } struct Bounds { float minX = 0, minY = 0, minZ = 0; float maxX = 0, maxY = 0, maxZ = 0; bool valid = false; void expand(float x, float y, float z) { if (!valid) { minX = maxX = x; minY = maxY = y; minZ = maxZ = z; valid = true; return; } minX = std::min(minX, x); minY = std::min(minY, y); minZ = std::min(minZ, z); maxX = std::max(maxX, x); maxY = std::max(maxY, y); maxZ = std::max(maxZ, z); } float centerX() const { return 0.5f * (minX + maxX); } float centerY() const { return 0.5f * (minY + maxY); } float centerZ() const { return 0.5f * (minZ + maxZ); } float radius() const { const float dx = maxX - minX; const float dy = maxY - minY; const float dz = maxZ - minZ; return 0.5f * std::sqrt(dx * dx + dy * dy + dz * dz); } }; Bounds boundsOf(eve::model3d::ModelData *md) { Bounds b; const aiScene *scene = md->getScene(); if (!scene || !scene->mRootNode) return b; std::function walk = [&](const aiNode *node, const aiMatrix4x4 &parent) { const aiMatrix4x4 world = parent * node->mTransformation; for (unsigned i = 0; i < node->mNumMeshes; ++i) { const unsigned mi = node->mMeshes[i]; if (mi >= scene->mNumMeshes) continue; const aiMesh *mesh = scene->mMeshes[mi]; if (!mesh) continue; for (unsigned v = 0; v < mesh->mNumVertices; ++v) { const aiVector3D p = world * mesh->mVertices[v]; b.expand(p.x, p.y, p.z); } } for (unsigned c = 0; c < node->mNumChildren; ++c) walk(node->mChildren[c], world); }; walk(scene->mRootNode, aiMatrix4x4()); return b; } /** Upload every mesh with Assimp material tint/texture; frame camera; save PNG. * @param fallbackDiffuse optional path relative to test/ when Assimp has no map. * @param makeStyle optional factory: given Graphics*, return Mesh3D Shader for all entities. */ void renderModelSmoke(eve::model3d::ModelData *md, const char *pngName, const char *fallbackDiffuse = nullptr, std::function makeStyle = {}) { REQUIRE(md != nullptr); REQUIRE(md->getMeshCount() >= 1); REQUIRE(pngName != nullptr); const aiScene *scene = md->getScene(); REQUIRE(scene != nullptr); eve::window::Window *win = nullptr; Graphics *gfx = nullptr; openGfxWindow(win, gfx); resetScene3D(); Shader *styleShader = makeStyle ? makeStyle(gfx) : nullptr; Bounds b = boundsOf(md); REQUIRE(b.valid); const float cx = b.centerX(); const float cy = b.centerY(); const float cz = b.centerZ(); const float rad = std::max(0.5f, b.radius()); Texture *white = makeSolidGray(gfx, 255); Texture *fallbackTex = loadTextureFile(gfx, fallbackDiffuse); struct MatLook { Texture *tex = nullptr; float tr = 1.f, tg = 1.f, tb = 1.f; }; std::unordered_map matCache; int spawned = 0; std::function walk = [&](const aiNode *node, const aiMatrix4x4 &parent) { const aiMatrix4x4 world = parent * node->mTransformation; for (unsigned i = 0; i < node->mNumMeshes; ++i) { const unsigned mi = node->mMeshes[i]; if (mi >= scene->mNumMeshes) continue; const aiMesh *ai = scene->mMeshes[mi]; if (!ai || ai->mNumFaces == 0) continue; Mesh *mesh = gfx->newMeshFromAssimp(*ai, world); REQUIRE(mesh != nullptr); REQUIRE_GT(mesh->indexCount, 0); MatLook look{white, 1.f, 1.f, 1.f}; const unsigned matIndex = ai->mMaterialIndex; auto cached = matCache.find(matIndex); if (cached != matCache.end()) { look = cached->second; } else if (scene->mMaterials && matIndex < scene->mNumMaterials) { const aiMaterial *mat = scene->mMaterials[matIndex]; aiColor3D kd(1.f, 1.f, 1.f); mat->Get(AI_MATKEY_COLOR_DIFFUSE, kd); Texture *fromMat = loadAssimpDiffuseTexture(gfx, scene, mat); if (fromMat) { look.tex = fromMat; look.tr = kd.r; look.tg = kd.g; look.tb = kd.b; } else if (fallbackTex) { look.tex = fallbackTex; look.tr = look.tg = look.tb = 1.f; } else { look.tex = makeSolidRGB(gfx, uint8_t(std::clamp(kd.r, 0.f, 1.f) * 255.f + 0.5f), uint8_t(std::clamp(kd.g, 0.f, 1.f) * 255.f + 0.5f), uint8_t(std::clamp(kd.b, 0.f, 1.f) * 255.f + 0.5f)); look.tr = look.tg = look.tb = 1.f; } matCache[matIndex] = look; } else if (fallbackTex) { look.tex = fallbackTex; matCache[matIndex] = look; } auto *ent = Renderable3D::create(); ent->meshRenderer()->mesh = mesh; ent->meshRenderer()->texture = look.tex; ent->meshRenderer()->shader = styleShader; ent->meshRenderer()->visible = true; ent->setTint(look.tr, look.tg, look.tb, 1.f); ++spawned; } for (unsigned c = 0; c < node->mNumChildren; ++c) walk(node->mChildren[c], world); }; REQUIRE(scene->mRootNode != nullptr); walk(scene->mRootNode, aiMatrix4x4()); REQUIRE(spawned >= 1); auto *cam = Camera3D::createCamera(); // 3/4 view: above + along +Z. Fit clip planes to asset scale (Rock1 is ~1e3 units). cam->setTarget(cx, cy, cz); cam->setEye(cx + rad * 0.9f, cy + rad * 0.55f, cz + rad * 1.8f); cam->data()->nearZ = std::max(0.05f, rad * 0.01f); cam->data()->farZ = std::max(100.f, rad * 20.f); auto *hud = Renderable2D::create(); hud->transform()->x = 0; hud->transform()->y = 0; hud->sprite()->width = 2; hud->sprite()->height = 2; hud->sprite()->r = 0.f; hud->sprite()->g = 0.f; hud->sprite()->b = 0.f; gfx->setScreenReadbackEnabled(true); gfx->setBackgroundColor(Color(0.12f, 0.14f, 0.18f, 1.f)); // Light rays travel from camera toward the subject (matches RenderSystem3D lighting test). const float lx = cx - (cx + rad * 0.9f); const float ly = cy - (cy + rad * 0.55f); const float lz = cz - (cz + rad * 1.8f); RenderSystem3D::setDirectionalLight(lx, ly + rad * 1.2f, lz, 1.8f, 1.8f, 1.8f); // ~1s live rotation so the asset is visibly on screen before PNG capture. for (int i = 0; i < 60; ++i) { if (ecs::current()->getManager() != nullptr) { auto view = ecs::View(); for (auto it = view.begin(); it != view.end(); ++it) { auto [xf, mr] = *it; if (!mr->visible) continue; xf->yaw = float(i) * 0.05f; } } RenderSystem3D::render(*gfx); RenderSystem::render(*gfx); SDL_Event e; while (SDL_PollEvent(&e)) { if (e.type == SDL_QUIT) break; } SDL_Delay(16); } Color mid = gfx->getPixel(gfx->getWidth() / 2, gfx->getHeight() / 2); CHECK(luma(mid) > 0.05f); eve::image::ImageData *frame = gfx->newImageData(); REQUIRE(frame != nullptr); eve::filesystem::FileData *png = frame->encode(medialoader::FormatHandler::ENCODED_PNG, pngName, false); REQUIRE(png != nullptr); REQUIRE(png->getSize() > 0); // Under the CMake test binary dir (build/.../test/out), already gitignored via build*/. const std::string outDir = std::string(EVENGINE_TEST_BINARY_DIR) + "/out"; std::error_code ec; std::filesystem::create_directories(outDir, ec); const std::string outPath = outDir + "/" + pngName; { std::ofstream out(outPath, std::ios::binary); REQUIRE(out.good()); out.write(static_cast(png->getData()), static_cast(png->getSize())); REQUIRE(out.good()); } std::printf("model3d render saved: %s (materials=%u cached=%zu style=%s)\n", outPath.c_str(), scene->mNumMaterials, matCache.size(), styleShader ? "yes" : "no"); delete png; delete frame; win->close(); } } // namespace // Minimal unit cube as Wavefront OBJ (same shape as medialoader model tests). static const char kCubeObj[] = "v -0.5 -0.5 -0.5\n" "v 0.5 -0.5 -0.5\n" "v 0.5 0.5 -0.5\n" "v -0.5 0.5 -0.5\n" "v -0.5 -0.5 0.5\n" "v 0.5 -0.5 0.5\n" "v 0.5 0.5 0.5\n" "v -0.5 0.5 0.5\n" "f 1 2 3\n" "f 1 3 4\n" "f 5 6 7\n" "f 5 7 8\n" "f 1 2 6\n" "f 1 6 5\n" "f 2 3 7\n" "f 2 7 6\n" "f 3 4 8\n" "f 3 8 7\n" "f 4 1 5\n" "f 4 5 8\n"; TEST_CASE("model3d.newModelData.objFromMemory") { auto *mod = eve::model3d::Model3D::create(); eve::data::ByteData data(kCubeObj, sizeof(kCubeObj) - 1); auto *md = mod->newModelData(&data, ".obj"); REQUIRE(md != nullptr); CHECK(!md->empty()); CHECK(md->getMeshCount() >= 1); CHECK(md->getVertexCount(0) > 0); CHECK(md->getFaceCount(0) > 0); CHECK(md->getMesh(0) != nullptr); CHECK(md->getScene() != nullptr); CHECK(md->getMaterialCount() >= 0); // Assimp's OBJ importer always computes per-face normals even without // `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; } TEST_CASE("model3d.newModelData.emptyThrows") { auto *mod = eve::model3d::Model3D::create(); bool threw = false; try { mod->newModelData(nullptr, ".obj"); } catch (const eve::Exception &) { threw = true; } CHECK(threw); } TEST_CASE("model3d.newModelData.invalidMeshIndexThrows") { auto *mod = eve::model3d::Model3D::create(); eve::data::ByteData data(kCubeObj, sizeof(kCubeObj) - 1); auto *md = mod->newModelData(&data, ".obj"); REQUIRE(md != nullptr); auto expectThrows = [](const std::function &fn) { try { fn(); } catch (const eve::Exception &) { return true; } return false; }; CHECK(expectThrows([&] { md->getVertexCount(999); })); CHECK(expectThrows([&] { md->getFaceCount(999); })); CHECK(expectThrows([&] { md->hasNormals(999); })); CHECK(expectThrows([&] { md->hasTexCoords(999); })); CHECK(md->getMesh(999) == nullptr); delete md; } TEST_CASE("model3d.newModelDataFromFile.missingThrows") { auto *mod = eve::model3d::Model3D::create(); bool threw = false; try { mod->newModelDataFromFile("definitely_missing_eve_model3d.obj"); } catch (const eve::Exception &) { threw = true; } CHECK(threw); } TEST_CASE("model3d.newModelDataFromFile.cached") { const std::string objPath = pathBesideThisSource("sofa.obj"); REQUIRE(!readBinaryFile(objPath).empty()); std::string testDir = pathBesideThisSource(""); if (!testDir.empty() && (testDir.back() == '/' || testDir.back() == '\\')) testDir.pop_back(); auto *fs = eve::filesystem::Filesystem::create(); REQUIRE(fs->setIdentity("ev_ut_model3d_cache", true)); REQUIRE(fs->setupWriteDirectory()); fs->allowMountingForPath(testDir); REQUIRE(fs->mount(testDir, "", false)); auto *mod = eve::model3d::Model3D::create(); eve::model3d::ModelData *a = mod->newModelDataFromFile("sofa.obj"); eve::model3d::ModelData *b = mod->newModelDataFromFile("sofa.obj"); REQUIRE(a != nullptr); CHECK(a == b); // identical requests share one decoded scene eve::model3d::ModelLoadOptions opt; opt.joinIdenticalVertices = false; eve::model3d::ModelData *c = mod->newModelDataFromFile("sofa.obj", opt); REQUIRE(c != nullptr); CHECK(c != a); // different decode options are a different cache entry CHECK(fs->unmount(testDir)); } TEST_CASE("model3d.Model3D.getName") { auto *mod = eve::model3d::Model3D::create(); 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); REQUIRE(!bytes.empty()); auto *mod = eve::model3d::Model3D::create(); eve::data::ByteData data(bytes.data(), bytes.size()); auto *md = mod->newModelData(&data, ".fbx"); REQUIRE(md != nullptr); CHECK(!md->empty()); CHECK(md->getMeshCount() >= 1); CHECK(md->getScene() != nullptr); CHECK(md->hasTexCoords(0)); int totalVerts = 0; int totalFaces = 0; for (int i = 0; i < md->getMeshCount(); ++i) { CHECK(md->getMesh(i) != nullptr); int verts = md->getVertexCount(i); int faces = md->getFaceCount(i); CHECK(verts > 0); CHECK(faces > 0); totalVerts += verts; totalFaces += faces; } CHECK(totalVerts > 0); CHECK(totalFaces > 0); delete md; } TEST_CASE("model3d.newModelDataFromFile.sofaObjMtl") { const std::string objPath = pathBesideThisSource("sofa.obj"); const std::string mtlPath = pathBesideThisSource("sofa.mtl"); REQUIRE(!readBinaryFile(objPath).empty()); REQUIRE(!readBinaryFile(mtlPath).empty()); std::string testDir = pathBesideThisSource(""); if (!testDir.empty() && (testDir.back() == '/' || testDir.back() == '\\')) testDir.pop_back(); auto *fs = eve::filesystem::Filesystem::create(); REQUIRE(fs->setIdentity("ev_ut_model3d_sofa", true)); REQUIRE(fs->setupWriteDirectory()); fs->allowMountingForPath(testDir); REQUIRE(fs->mount(testDir, "", false)); auto *mod = eve::model3d::Model3D::create(); auto *md = mod->newModelDataFromFile("sofa.obj"); REQUIRE(md != nullptr); CHECK(!md->empty()); CHECK(md->getMeshCount() >= 1); // Blender MTL lists 5 materials; Assimp may add a default. CHECK(md->getMaterialCount() >= 5); CHECK(md->getScene() != nullptr); int totalVerts = 0; int totalFaces = 0; for (int i = 0; i < md->getMeshCount(); ++i) { CHECK(md->getMesh(i) != nullptr); int verts = md->getVertexCount(i); int faces = md->getFaceCount(i); CHECK(verts > 0); CHECK(faces > 0); totalVerts += verts; totalFaces += faces; } CHECK(totalVerts > 0); CHECK(totalFaces > 0); CHECK(fs->unmount(testDir)); } TEST_CASE("model3d.render.rock1Fbx") { const std::string path = pathBesideThisSource("Rock1.fbx"); auto bytes = readBinaryFile(path); REQUIRE(!bytes.empty()); REQUIRE(!readBinaryFile(pathBesideThisSource("Textures/Rock1_Diffuse.png")).empty()); auto *mod = eve::model3d::Model3D::create(); eve::data::ByteData data(bytes.data(), bytes.size()); auto *md = mod->newModelData(&data, ".fbx"); REQUIRE(md != nullptr); // FBX material has no embedded map path; bind companion diffuse next to the asset. renderModelSmoke(md, "model3d_rock1.png", "Textures/Rock1_Diffuse.png"); delete md; } TEST_CASE("model3d.render.rock1ToonShader") { const std::string path = pathBesideThisSource("Rock1.fbx"); auto bytes = readBinaryFile(path); REQUIRE(!bytes.empty()); REQUIRE(!readBinaryFile(pathBesideThisSource("Textures/Rock1_Diffuse.png")).empty()); auto *mod = eve::model3d::Model3D::create(); eve::data::ByteData data(bytes.data(), bytes.size()); auto *md = mod->newModelData(&data, ".fbx"); REQUIRE(md != nullptr); renderModelSmoke(md, "model3d_rock1_toon.png", "Textures/Rock1_Diffuse.png", [](Graphics *gfx) -> Shader * { std::vector vert(mesh3d_toon_vert_spv, mesh3d_toon_vert_spv + mesh3d_toon_vert_spv_count); std::vector frag(mesh3d_toon_frag_spv, mesh3d_toon_frag_spv + mesh3d_toon_frag_spv_count); Shader *toon = gfx->newMeshShaderFromSpv(vert, frag); if (!toon || !toon->gpuHandle) throw eve::Exception("failed to create toon mesh shader"); toon->declareFloat("bands"); toon->declareFloat("rimPower"); toon->declareFloat("rimStrength"); toon->declareFloat("posterize"); toon->sendFloat("bands", 4.f); toon->sendFloat("rimPower", 2.8f); toon->sendFloat("rimStrength", 0.55f); toon->sendFloat("posterize", 6.f); return toon; }); delete md; } TEST_CASE("model3d.render.sofaObjMtl") { const std::string objPath = pathBesideThisSource("sofa.obj"); const std::string mtlPath = pathBesideThisSource("sofa.mtl"); REQUIRE(!readBinaryFile(objPath).empty()); REQUIRE(!readBinaryFile(mtlPath).empty()); std::string testDir = pathBesideThisSource(""); if (!testDir.empty() && (testDir.back() == '/' || testDir.back() == '\\')) testDir.pop_back(); auto *fs = eve::filesystem::Filesystem::create(); REQUIRE(fs->setIdentity("ev_ut_model3d_sofa_render", true)); REQUIRE(fs->setupWriteDirectory()); fs->allowMountingForPath(testDir); REQUIRE(fs->mount(testDir, "", false)); auto *mod = eve::model3d::Model3D::create(); auto *md = mod->newModelDataFromFile("sofa.obj"); REQUIRE(md != nullptr); renderModelSmoke(md, "model3d_sofa.png"); CHECK(fs->unmount(testDir)); } TEST_CASE("model3d.materialApi.objMtl") { const std::string objPath = pathBesideThisSource("sofa.obj"); const std::string mtlPath = pathBesideThisSource("sofa.mtl"); REQUIRE(!readBinaryFile(objPath).empty()); REQUIRE(!readBinaryFile(mtlPath).empty()); std::string testDir = pathBesideThisSource(""); if (!testDir.empty() && (testDir.back() == '/' || testDir.back() == '\\')) testDir.pop_back(); auto *fs = eve::filesystem::Filesystem::create(); REQUIRE(fs->setIdentity("ev_ut_model3d_material_obj", true)); REQUIRE(fs->setupWriteDirectory()); fs->allowMountingForPath(testDir); REQUIRE(fs->mount(testDir, "", false)); auto *mod = eve::model3d::Model3D::create(); auto *md = mod->newModelDataFromFile("sofa.obj"); REQUIRE(md != nullptr); REQUIRE(md->getMaterialCount() >= 5); const int mi = md->getMaterialIndex(0); REQUIRE(mi >= 0); REQUIRE(mi < md->getMaterialCount()); const std::string name = md->getMaterialName(mi); REQUIRE(!name.empty()); // Blender Kd lands in DIFFUSE (BASE_COLOR fallback). const float r = md->getMaterialBaseColorR(mi); const float g = md->getMaterialBaseColorG(mi); const float b = md->getMaterialBaseColorB(mi); CHECK(r > 0.05f); CHECK(g > 0.05f); CHECK(b > 0.05f); CHECK(md->getMaterialBaseColorA(mi) == 1.f); // No PBR factors in OBJ/MTL: defaults. CHECK(md->getMaterialMetallicFactor(mi) == 0.f); CHECK(md->getMaterialRoughnessFactor(mi) == 0.45f); // sofa.mtl has no map_Kd. CHECK(md->getMaterialTextureSlotCount(mi, "diffuse") == 0); CHECK(md->getMaterialTexturePath(mi, "diffuse").empty()); CHECK(md->getMaterialTextureEmbeddedIndex(mi, "diffuse") == -1); CHECK(md->getMaterialTextureSlotCount(mi, "base_color") == 0); // Unknown texture type names are rejected. CHECK(md->getMaterialTextureSlotCount(mi, "bogus") == 0); CHECK(md->getMaterialTexturePath(mi, "bogus").empty()); delete md; CHECK(fs->unmount(testDir)); } TEST_CASE("model3d.materialApi.gltf") { // CesiumMan.gltf references the external CesiumMan_data.bin buffer, so the // glTF must be loaded through the VFS (in-memory decode cannot resolve it). const std::string dir = pathBesideThisSource("assets/skinned/cesium_man/glTF/"); REQUIRE(!readBinaryFile(dir + "CesiumMan.gltf").empty()); REQUIRE(!readBinaryFile(dir + "CesiumMan_data.bin").empty()); auto *fs = eve::filesystem::Filesystem::create(); REQUIRE(fs->setIdentity("ev_ut_model3d_material_gltf", true)); REQUIRE(fs->setupWriteDirectory()); fs->allowMountingForPath(dir); REQUIRE(fs->mount(dir, "", false)); auto *mod = eve::model3d::Model3D::create(); auto *md = mod->newModelDataFromFile("CesiumMan.gltf"); REQUIRE(md != nullptr); REQUIRE(md->getMaterialCount() >= 1); CHECK(md->getMaterialIndex(0) == 0); CHECK(!md->getMaterialName(0).empty()); CHECK(md->hasNormals(0)); CHECK(md->hasTexCoords(0)); // CesiumMan material: metallic 0 / roughness 1, external base color JPEG. CHECK(md->getMaterialMetallicFactor(0) == 0.f); CHECK(md->getMaterialRoughnessFactor(0) == 1.f); CHECK(md->getMaterialTextureSlotCount(0, "base_color") == 1); CHECK(md->getMaterialTexturePath(0, "base_color") == "CesiumMan_img0.jpg"); CHECK(md->getMaterialTextureEmbeddedIndex(0, "base_color") == -1); // Double-sided flag exists on glTF assets that set it; absence is fine here. CHECK(!md->getMaterialTwoSided(0)); CHECK(fs->unmount(dir)); } TEST_CASE("model3d.buildRenderable.objMtl") { std::string testDir = pathBesideThisSource(""); if (!testDir.empty() && (testDir.back() == '/' || testDir.back() == '\\')) testDir.pop_back(); auto *fs = eve::filesystem::Filesystem::create(); REQUIRE(fs->setIdentity("ev_ut_model3d_build_obj", true)); REQUIRE(fs->setupWriteDirectory()); fs->allowMountingForPath(testDir); REQUIRE(fs->mount(testDir, "", false)); auto *mod = eve::model3d::Model3D::create(); auto *md = mod->newModelDataFromFile("sofa.obj"); REQUIRE(md != nullptr); REQUIRE(md->getMeshCount() >= 1); eve::window::Window *win = nullptr; Graphics *gfx = nullptr; openGfxWindow(win, gfx); resetScene3D(); auto *ent = mod->createRenderable(gfx, md, 0); REQUIRE(ent != nullptr); REQUIRE(ent->meshRenderer()->mesh != nullptr); REQUIRE(ent->meshRenderer()->mesh->indexCount > 0); const int mi = md->getMaterialIndex(0); if (mi >= 0) { CHECK(ent->meshRenderer()->r == md->getMaterialBaseColorR(mi)); CHECK(ent->meshRenderer()->g == md->getMaterialBaseColorG(mi)); CHECK(ent->meshRenderer()->b == md->getMaterialBaseColorB(mi)); CHECK(ent->meshRenderer()->metallic == md->getMaterialMetallicFactor(mi)); CHECK(ent->meshRenderer()->roughness == md->getMaterialRoughnessFactor(mi)); } // Invalid indices return nullptr without throwing. CHECK(mod->createRenderable(gfx, md, 999) == nullptr); CHECK(eve::model3d::buildRenderable(*gfx, md, 999) == nullptr); CHECK(eve::model3d::buildRenderable(*gfx, nullptr, 0) == nullptr); win->close(); CHECK(fs->unmount(testDir)); } TEST_CASE("model3d.render.gltfEmbeddedUvOrientation") { // 4x2 texture: top row red, bottom row blue. const int tw = 4; const int th = 2; std::vector px(size_t(tw) * size_t(th) * 4); for (int y = 0; y < th; ++y) { for (int x = 0; x < tw; ++x) { uint8_t *p = px.data() + (size_t(y) * tw + size_t(x)) * 4; const bool top = (y == 0); p[0] = top ? 255 : 0; p[1] = 0; p[2] = top ? 0 : 255; p[3] = 255; } } [[maybe_unused]] auto *const imageModule = eve::image::Image::create(); eve::image::ImageData pngImg(tw, th, "RGBA8", px.data(), false); eve::filesystem::FileData *png = pngImg.encode(eve::image::ImageData::FormatHandler::ENCODED_PNG, "gltf_uv.png", false); REQUIRE(png != nullptr); REQUIRE(png->getSize() > 0); const std::string gltfText = makeUvOrientationGltf(base64Encode(static_cast(png->getData()), png->getSize())); delete png; auto *mod = eve::model3d::Model3D::create(); eve::data::ByteData data(gltfText.data(), gltfText.size()); auto *md = mod->newModelData(&data, ".gltf"); REQUIRE(md != nullptr); REQUIRE(md->getMeshCount() >= 1); const aiMesh *sourceMesh = md->getMesh(0); REQUIRE(sourceMesh != nullptr); REQUIRE(sourceMesh->mNumFaces > 0); const aiFace &sourceFace = sourceMesh->mFaces[0]; REQUIRE(sourceFace.mNumIndices == 3); const aiVector3D centroid = (sourceMesh->mVertices[sourceFace.mIndices[0]] + sourceMesh->mVertices[sourceFace.mIndices[1]] + sourceMesh->mVertices[sourceFace.mIndices[2]]) / 3.f; auto mapped = md->mapSurfacePointToUv(0, 0, centroid.x, centroid.y, centroid.z, 0); REQUIRE(mapped.ok()); const auto surfaceUv = std::move(mapped).takeValue(); const float expectedU = (sourceMesh->mTextureCoords[0][sourceFace.mIndices[0]].x + sourceMesh->mTextureCoords[0][sourceFace.mIndices[1]].x + sourceMesh->mTextureCoords[0][sourceFace.mIndices[2]].x) / 3.f; const float expectedV = (sourceMesh->mTextureCoords[0][sourceFace.mIndices[0]].y + sourceMesh->mTextureCoords[0][sourceFace.mIndices[1]].y + sourceMesh->mTextureCoords[0][sourceFace.mIndices[2]].y) / 3.f; CHECK(std::fabs(surfaceUv.u - expectedU) < 1e-5f); CHECK(std::fabs(surfaceUv.v - expectedV) < 1e-5f); CHECK(std::fabs(surfaceUv.barycentricA - 1.f / 3.f) < 1e-5f); CHECK(std::fabs(surfaceUv.barycentricB - 1.f / 3.f) < 1e-5f); CHECK(std::fabs(surfaceUv.barycentricC - 1.f / 3.f) < 1e-5f); // Material API on the synthetic file: PBR factors + embedded texture. CHECK(md->getMaterialMetallicFactor(0) == 0.f); CHECK(md->getMaterialRoughnessFactor(0) == 1.f); CHECK(md->getMaterialTextureSlotCount(0, "base_color") == 1); CHECK(md->getMaterialTexturePath(0, "base_color") == "*0"); CHECK(md->getMaterialTextureEmbeddedIndex(0, "base_color") == 0); CHECK(md->getEmbeddedTextureCount() == 1); CHECK(md->getEmbeddedTextureWidth(0) > 0); // compressed blob: width == byte length CHECK(md->getEmbeddedTextureHeight(0) == 0); // compressed blob eve::image::ImageData *decoded = md->getEmbeddedTextureImageData(0); REQUIRE(decoded != nullptr); CHECK(decoded->getWidth() == tw); CHECK(decoded->getHeight() == th); CHECK(decoded->getFormat() == "RGBA8"); { const auto top = decoded->getPixel(0, 0); const auto bot = decoded->getPixel(0, 1); CHECK(top.r > top.b); CHECK(bot.b > bot.r); } delete decoded; eve::window::Window *win = nullptr; Graphics *gfx = nullptr; openGfxWindow(win, gfx); resetScene3D(); auto *ent = mod->createRenderable(gfx, md, 0); REQUIRE(ent != nullptr); REQUIRE(ent->meshRenderer()->mesh != nullptr); REQUIRE(ent->meshRenderer()->texture != nullptr); // embedded texture decoded + uploaded auto *cam = Camera3D::createCamera(); cam->setEye(0.f, 0.f, 3.f); cam->setTarget(0.f, 0.f, 0.f); cam->setUp(0.f, 1.f, 0.f); cam->data()->nearZ = 0.05f; cam->data()->farZ = 100.f; RenderSystem3D::setDirectionalLight(0.f, 0.f, -1.f, 1.5f, 1.5f, 1.5f); gfx->setScreenReadbackEnabled(true); gfx->setBackgroundColor(Color(0.f, 0.f, 0.f, 1.f)); for (int i = 0; i < 8; ++i) { RenderSystem3D::render(*gfx); RenderSystem::render(*gfx); SDL_Event e; while (SDL_PollEvent(&e)) { if (e.type == SDL_QUIT) break; } SDL_Delay(16); } // Straight-on quad: world +Y (texture v=0, red) must land on the upper half. const Color top = gfx->getPixel(gfx->getWidth() / 2, gfx->getHeight() / 4); const Color bot = gfx->getPixel(gfx->getWidth() / 2, gfx->getHeight() * 3 / 4); CHECK(luma(top) > 0.05f); CHECK(luma(bot) > 0.05f); CHECK(top.r > top.b); // red on top → not V-flipped CHECK(bot.b > bot.r); // blue on bottom eve::image::ImageData *frame = gfx->newImageData(); REQUIRE(frame != nullptr); eve::filesystem::FileData *framePng = frame->encode(medialoader::FormatHandler::ENCODED_PNG, "model3d_gltf_uv.png", false); REQUIRE(framePng != nullptr); const std::string outDir = std::string(EVENGINE_TEST_BINARY_DIR) + "/out"; std::error_code ec; std::filesystem::create_directories(outDir, ec); const std::string outPath = outDir + "/model3d_gltf_uv.png"; { std::ofstream out(outPath, std::ios::binary); REQUIRE(out.good()); out.write(static_cast(framePng->getData()), static_cast(framePng->getSize())); REQUIRE(out.good()); } std::printf("model3d render saved: %s\n", outPath.c_str()); delete framePng; delete frame; win->close(); delete md; } TEST_CASE("model3d.render.cesiumManGltf") { const std::string dir = pathBesideThisSource("assets/skinned/cesium_man/glTF/"); REQUIRE(!readBinaryFile(dir + "CesiumMan.gltf").empty()); REQUIRE(!readBinaryFile(dir + "CesiumMan_img0.jpg").empty()); auto *fs = eve::filesystem::Filesystem::create(); REQUIRE(fs->setIdentity("ev_ut_model3d_gltf", true)); REQUIRE(fs->setupWriteDirectory()); fs->allowMountingForPath(dir); REQUIRE(fs->mount(dir, "", false)); auto *mod = eve::model3d::Model3D::create(); auto *md = mod->newModelDataFromFile("CesiumMan.gltf"); REQUIRE(md != nullptr); REQUIRE(md->getMeshCount() >= 1); eve::window::Window *win = nullptr; Graphics *gfx = nullptr; openGfxWindow(win, gfx); resetScene3D(); auto *ent = mod->createRenderable(gfx, md, 0); REQUIRE(ent != nullptr); REQUIRE(ent->meshRenderer()->mesh != nullptr); REQUIRE(ent->meshRenderer()->texture != nullptr); // CesiumMan_img0.jpg resolved via VFS Bounds b = boundsOf(md); REQUIRE(b.valid); const float cx = b.centerX(); const float cy = b.centerY(); const float cz = b.centerZ(); const float rad = std::max(0.5f, b.radius()); auto *cam = Camera3D::createCamera(); cam->setTarget(cx, cy, cz); cam->setEye(cx + rad * 0.9f, cy + rad * 0.55f, cz + rad * 1.8f); cam->data()->nearZ = std::max(0.05f, rad * 0.01f); cam->data()->farZ = std::max(100.f, rad * 20.f); RenderSystem3D::setDirectionalLight(-(cx + rad * 0.9f), -(cy + rad * 0.55f) + rad * 1.2f, -(cz + rad * 1.8f), 1.8f, 1.8f, 1.8f); gfx->setScreenReadbackEnabled(true); gfx->setBackgroundColor(Color(0.12f, 0.14f, 0.18f, 1.f)); for (int i = 0; i < 60; ++i) { if (ecs::current()->getManager() != nullptr) { auto view = ecs::View(); for (auto it = view.begin(); it != view.end(); ++it) { auto [xf, mr] = *it; if (!mr->visible) continue; xf->yaw = float(i) * 0.05f; } } RenderSystem3D::render(*gfx); RenderSystem::render(*gfx); SDL_Event e; while (SDL_PollEvent(&e)) { if (e.type == SDL_QUIT) break; } SDL_Delay(16); } const Color mid = gfx->getPixel(gfx->getWidth() / 2, gfx->getHeight() / 2); CHECK(luma(mid) > 0.05f); [[maybe_unused]] auto *const imageModule = eve::image::Image::create(); eve::image::ImageData *frame = gfx->newImageData(); REQUIRE(frame != nullptr); eve::filesystem::FileData *png = frame->encode(medialoader::FormatHandler::ENCODED_PNG, "model3d_cesium_man.png", false); REQUIRE(png != nullptr); const std::string outDir = std::string(EVENGINE_TEST_BINARY_DIR) + "/out"; std::error_code ec; std::filesystem::create_directories(outDir, ec); const std::string outPath = outDir + "/model3d_cesium_man.png"; { std::ofstream out(outPath, std::ios::binary); REQUIRE(out.good()); out.write(static_cast(png->getData()), static_cast(png->getSize())); REQUIRE(out.good()); } std::printf("model3d render saved: %s (materials=%d)\n", outPath.c_str(), md->getMaterialCount()); delete png; delete frame; win->close(); CHECK(fs->unmount(dir)); }