#include "zeroerr/assert.h" #include "zeroerr/unittest.h" #include "Fixtures.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "data/ByteData.h" #include "filesystem/Filesystem.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/Material.h" #include "graphics/Outline.h" #include "graphics/Quad.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/Light.h" #include "graphics/Mesh.h" #include "graphics/RenderControl.h" #include "graphics/RenderSystem.h" #include "graphics/RenderSystem3D.h" #include "image/Image.h" #include "image/ImageData.h" #include "model3d/Model3D.h" #include "model3d/ModelData.h" #include "system/System.h" #include "timer/Timer.h" #include "window/Window.h" #include "RenderImageAudit.h" 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()); } bool fileExists(const std::string &path) { return std::filesystem::is_regular_file(path); } float luma(const Color &c) { return 0.2126f * c.r + 0.7152f * c.g + 0.0722f * c.b; } 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; data->envMap = nullptr; data->envIntensity = 1.f; } } if (ecs::current()->getManager() != nullptr) { auto lightView = ecs::View(); for (auto it = lightView.begin(); it != lightView.end(); ++it) { auto [d] = *it; d->enabled = 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 *makeSolid(Graphics *gfx, uint8_t r, uint8_t g, uint8_t b) { const uint8_t px[4] = {r, g, b, 255}; return gfx->newTexture(1, 1, px); } Texture *makeSolidCubemap(Graphics *gfx, uint8_t r, uint8_t g, uint8_t b, int face = 8) { std::vector faces(size_t(face) * size_t(face) * 4u * 6u); for (size_t i = 0; i < faces.size(); i += 4) { faces[i + 0] = r; faces[i + 1] = g; faces[i + 2] = b; faces[i + 3] = 255; } return gfx->newCubemap(face, faces.data()); } /** Studio-like face-colored env so metal reflections read clearly in flight. */ Texture *makeStudioCubemap(Graphics *gfx, int face = 16) { const uint8_t faceRgb[6][3] = { {220, 180, 140}, // +X warm {120, 150, 210}, // -X cool {245, 245, 250}, // +Y skylight {40, 40, 45}, // -Y ground {200, 210, 230}, // +Z {180, 160, 140}, // -Z }; const size_t faceBytes = size_t(face) * size_t(face) * 4u; std::vector faces(faceBytes * 6u); for (int f = 0; f < 6; ++f) { uint8_t *dst = faces.data() + size_t(f) * faceBytes; for (size_t i = 0; i < faceBytes; i += 4) { dst[i + 0] = faceRgb[f][0]; dst[i + 1] = faceRgb[f][1]; dst[i + 2] = faceRgb[f][2]; dst[i + 3] = 255; } } return gfx->newCubemap(face, faces.data()); } Texture *textureFromImageData(Graphics *gfx, eve::image::ImageData *img) { if (!img) return nullptr; // Classic flythroughs need mipmaps + anisotropy; engine defaults stay off for pixel-art paths. return gfx->newTexture(img, TextureCreateInfo::withMipmaps(true)); } Texture *loadAssimpDiffuseTexture(Graphics *gfx, const aiScene *scene, const aiMaterial *mat, const std::string &assetDir) { if (!scene || !mat) return nullptr; aiString path; // glTF PBR stores albedo as BASE_COLOR; older assets use DIFFUSE. if (mat->GetTexture(aiTextureType_BASE_COLOR, 0, &path) != AI_SUCCESS && 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(); 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) { 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; } } return nullptr; } std::string full = assetDir; if (!full.empty() && full.back() != '/' && full.back() != '\\') full.push_back('/'); full += p; // Assimp sometimes returns nested paths with backslashes. for (char &c : full) { if (c == '\\') c = '/'; } auto raw = readBinaryFile(full); if (raw.empty()) { // Try basename only under assetDir (glTF often stores URI as filename). const char *base = std::strrchr(p, '/'); if (!base) base = std::strrchr(p, '\\'); base = base ? base + 1 : p; full = assetDir; if (!full.empty() && full.back() != '/' && full.back() != '\\') full.push_back('/'); full += base; raw = readBinaryFile(full); } if (raw.empty()) return nullptr; eve::data::ByteData bytes(raw.data(), raw.size()); try { eve::image::ImageData *img = eve::image::Image::create()->newImageData(&bytes); Texture *t = textureFromImageData(gfx, img); delete img; return t; } catch (...) { return nullptr; } } struct Bounds { bool valid = false; float minX = 0, minY = 0, minZ = 0; float maxX = 0, maxY = 0, maxZ = 0; 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 { if (!valid) return 1.f; 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); } }; void expandBoundsTransformed(Bounds &b, const aiMesh *mesh, const aiMatrix4x4 &world) { if (!mesh) return; for (unsigned v = 0; v < mesh->mNumVertices; ++v) { const aiVector3D p = world * mesh->mVertices[v]; b.expand(p.x, p.y, p.z); } } Bounds boundsOfScene(const aiScene *scene) { Bounds b; 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; expandBoundsTransformed(b, scene->mMeshes[mi], world); } for (unsigned c = 0; c < node->mNumChildren; ++c) walk(node->mChildren[c], world); }; walk(scene->mRootNode, aiMatrix4x4()); return b; } void addHud() { 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; } struct CamKey { float eyeX, eyeY, eyeZ; float tgtX, tgtY, tgtZ; }; CamKey lerpKey(const CamKey &a, const CamKey &b, float t) { const float u = std::clamp(t, 0.f, 1.f); return CamKey{a.eyeX + (b.eyeX - a.eyeX) * u, a.eyeY + (b.eyeY - a.eyeY) * u, a.eyeZ + (b.eyeZ - a.eyeZ) * u, a.tgtX + (b.tgtX - a.tgtX) * u, a.tgtY + (b.tgtY - a.tgtY) * u, a.tgtZ + (b.tgtZ - a.tgtZ) * u}; } void applyCam(Camera3D *cam, const CamKey &k) { cam->setEye(k.eyeX, k.eyeY, k.eyeZ); cam->setTarget(k.tgtX, k.tgtY, k.tgtZ); } enum class RenderCfg { AmbientOnly, DirectionalLit, DirectionalShadows, MultiPointLit, IblReflections, DirAndPoint, ShadowsAndIbl, FullLit, }; const char *cfgName(RenderCfg c) { switch (c) { case RenderCfg::AmbientOnly: return "ambient"; case RenderCfg::DirectionalLit: return "dir_lit"; case RenderCfg::DirectionalShadows: return "dir_shadows"; case RenderCfg::MultiPointLit: return "point_lit"; case RenderCfg::IblReflections: return "ibl_reflect"; case RenderCfg::DirAndPoint: return "dir_point"; case RenderCfg::ShadowsAndIbl: return "shadows_ibl"; case RenderCfg::FullLit: return "full_lit"; } return "unknown"; } bool cfgUsesIbl(RenderCfg c) { return c == RenderCfg::IblReflections || c == RenderCfg::ShadowsAndIbl || c == RenderCfg::FullLit; } struct SceneActors { Camera3D *cam = nullptr; Light3D *sun = nullptr; Light3D *pointA = nullptr; Light3D *pointB = nullptr; Texture *env = nullptr; std::vector ents; Bounds bounds; float sunR = 1.f, sunG = 0.97f, sunB = 0.92f; float sunI = 2.2f; struct MatBackup { float metallic = 0.f; float roughness = 1.f; }; std::vector matBackup; }; void setSunColor(SceneActors &a, float r, float g, float b, float intensity) { a.sunR = r; a.sunG = g; a.sunB = b; a.sunI = intensity; if (a.sun) a.sun->setColor(r, g, b, intensity); } void applySun(SceneActors &a, bool castShadow, float intensityScale) { a.sun->setEnabled(true); a.sun->setCastShadow(castShadow); if (castShadow) a.sun->setShadowStrength(1.f); a.sun->setColor(a.sunR, a.sunG, a.sunB, std::max(0.05f, a.sunI * intensityScale)); } void disableAllLights(SceneActors &a) { if (a.sun) a.sun->setEnabled(false); if (a.pointA) a.pointA->setEnabled(false); if (a.pointB) a.pointB->setEnabled(false); RenderSystem3D::setDirectionalLight(0.f, 1.f, 0.f, 0.f, 0.f, 0.f); } void restoreMaterials(SceneActors &a) { if (a.matBackup.empty()) { a.matBackup.resize(a.ents.size()); for (size_t i = 0; i < a.ents.size(); ++i) { auto mr = a.ents[i]->meshRenderer(); a.matBackup[i] = {mr->metallic, mr->roughness}; } return; } const size_t n = std::min(a.ents.size(), a.matBackup.size()); for (size_t i = 0; i < n; ++i) { a.ents[i]->setMetallic(a.matBackup[i].metallic); a.ents[i]->setRoughness(a.matBackup[i].roughness); } } void applyConfig(SceneActors &a, RenderCfg cfg, bool polishMetals) { disableAllLights(a); a.cam->setEnvMap(nullptr); a.cam->setEnvIntensity(0.f); restoreMaterials(a); switch (cfg) { case RenderCfg::AmbientOnly: a.cam->setAmbient(0.26f, 0.26f, 0.28f); break; case RenderCfg::DirectionalLit: a.cam->setAmbient(0.06f, 0.06f, 0.07f); applySun(a, false, 1.0f); break; case RenderCfg::DirectionalShadows: a.cam->setAmbient(0.10f, 0.10f, 0.12f); applySun(a, true, 1.18f); break; case RenderCfg::MultiPointLit: a.cam->setAmbient(0.03f, 0.03f, 0.04f); a.pointA->setEnabled(true); a.pointB->setEnabled(true); break; case RenderCfg::IblReflections: a.cam->setAmbient(0.02f, 0.02f, 0.03f); applySun(a, false, 0.4f); a.cam->setEnvMap(a.env); a.cam->setEnvIntensity(1.0f); if (polishMetals) { for (auto *e : a.ents) { e->setMetallic(std::max(e->meshRenderer()->metallic, 0.85f)); e->setRoughness(std::min(e->meshRenderer()->roughness, 0.2f)); } } break; case RenderCfg::DirAndPoint: a.cam->setAmbient(0.04f, 0.04f, 0.05f); applySun(a, false, 0.91f); a.pointA->setEnabled(true); a.pointB->setEnabled(true); break; case RenderCfg::ShadowsAndIbl: a.cam->setAmbient(0.03f, 0.03f, 0.04f); applySun(a, true, 1.0f); a.cam->setEnvMap(a.env); a.cam->setEnvIntensity(1.1f); break; case RenderCfg::FullLit: a.cam->setAmbient(0.03f, 0.03f, 0.04f); applySun(a, true, 0.91f); a.pointA->setEnabled(true); a.pointB->setEnabled(true); a.cam->setEnvMap(a.env); a.cam->setEnvIntensity(1.0f); break; } } void warmPresent(Graphics *gfx) { for (int i = 0; i < 2; ++i) { RenderSystem3D::render(*gfx); RenderSystem::render(*gfx); } } /** Hold the live view at `hz` so lighting/AO/GI can be inspected. Perf benches skip this. */ void paceViewFrame(eve::timer::Timer *timer, float frameStart, float hz) { if (!timer) return; const float budget = 1.f / std::max(hz, 1.f); const float remain = budget - (timer->getTime() - frameStart); if (remain < 0.001f) return; eve::system::System::create()->sleepMilliseconds(int(remain * 1000.f + 0.5f)); } float viewSecondsPerPhase() { const char *env = std::getenv("EVENGINE_VIEW_SECONDS"); if (env && env[0]) { const float s = float(std::atof(env)); return s > 0.2f ? s : 0.2f; } return 4.f; } float viewHz() { const char *env = std::getenv("EVENGINE_VIEW_HZ"); if (env && env[0]) { const float h = float(std::atof(env)); return h > 1.f ? h : 1.f; } return 30.f; } /** Extra per-phase / diagnostic PNGs. Default is one hero shot per scene. */ bool dumpPhaseFrames() { const char *env = std::getenv("EVENGINE_DUMP_PHASES"); return env && env[0] && env[0] != '0'; } std::string classicOutDir() { return std::string(EVENGINE_TEST_BINARY_DIR) + "/out/classic_scenes"; } void saveFramePng(Graphics * /*gfx*/, const std::string &name, eve::image::ImageData *frame) { const std::string outDir = classicOutDir(); REQUIRE(saveImagePng(*frame, outDir + "/" + name)); std::printf("ClassicScenes frame: %s\n", (outDir + "/" + name).c_str()); } /** CPU-only audit of the already-read-back snapshot. No extra present/readback. */ void auditCapturedFrame(eve::image::ImageData *frame, Graphics *gfx, const char *sceneTag, const char *phase) { REQUIRE(frame != nullptr); const Color c = gfx->getBackgroundColor(); RenderAuditConfig cfg{sceneTag, phase, "", 0}; // Stride 4: one pass over ~1/16 of pixels. Flicker needs a second GPU // readback and is left to graphics.imageAudit.pipelineConfigs. RenderAuditResult result = auditImage(*frame, cfg, RenderAuditBg{c.r, c.g, c.b}, /*step=*/4); if (result.hasDefects()) { const std::string outDir = classicOutDir(); appendAuditReport(outDir, result); if (result.hasSevere()) saveAuditOverlay(*frame, result, outDir); std::printf("ClassicScenes[%s] phase=%s image defects=%zu severe=%d — %s/image_audit.md\n", sceneTag, phase, result.defects.size(), int(result.hasSevere()), outDir.c_str()); } // Ambient-only product shots are dark-on-dark by design; occupancy < 2% is // not a missing-mesh failure. if (std::strcmp(phase, "ambient") != 0) REQUIRE(!result.hasSevere()); } void resetRenderControl(Graphics *gfx) { RenderControl *rc = gfx->getRenderControl(); if (!rc) return; rc->enable("depthTest"); rc->enable("shadow"); rc->enable("forward"); rc->enable("hair"); rc->enable("clustered"); rc->enable("ao"); rc->enable("gi"); rc->enable("aa"); rc->compile(); } /** Extra lighting / RenderControl snaps at the current camera. No extra fly frames. */ void snapStaticConfigGrid(Graphics *gfx, SceneActors &actors, const char *sceneTag, bool polishMetals) { struct Extra { RenderCfg cfg; const char *enableFeat; const char *disableFeat; const char *tag; int msaaSamples = -1; }; const Extra extras[] = { {RenderCfg::DirAndPoint, nullptr, nullptr, "dir_point"}, {RenderCfg::ShadowsAndIbl, nullptr, nullptr, "shadows_ibl"}, {RenderCfg::FullLit, nullptr, nullptr, "full_lit"}, {RenderCfg::DirectionalLit, "gbuffer", nullptr, "gbuffer"}, {RenderCfg::DirectionalLit, "gbufferAlbedo", nullptr, "gbuffer_albedo"}, {RenderCfg::DirectionalShadows, nullptr, "shadow", "no_shadow_pass"}, {RenderCfg::DirectionalShadows, nullptr, nullptr, "msaa_2x", 2}, {RenderCfg::DirectionalShadows, nullptr, nullptr, "msaa_off", 0}, }; RenderControl *rc = gfx->getRenderControl(); gfx->setScreenReadbackEnabled(false); for (const auto &ex : extras) { applyConfig(actors, ex.cfg, polishMetals && cfgUsesIbl(ex.cfg)); resetRenderControl(gfx); if (ex.enableFeat && rc) { rc->enable(ex.enableFeat); rc->compile(); } if (ex.disableFeat && rc) { rc->disable(ex.disableFeat); rc->compile(); } if (ex.msaaSamples >= 0) gfx->setMsaaSamples(ex.msaaSamples); std::printf("ClassicScenes[%s] extra=%s\n", sceneTag, ex.tag); for (int i = 0; i < 2; ++i) { RenderSystem3D::render(*gfx); RenderSystem::render(*gfx); } gfx->setScreenReadbackEnabled(true); RenderSystem3D::render(*gfx); RenderSystem::render(*gfx); eve::image::ImageData *snap = gfx->newImageData(); REQUIRE(snap != nullptr); if (dumpPhaseFrames()) saveFramePng(gfx, std::string(sceneTag) + "_" + ex.tag + ".png", snap); auditCapturedFrame(snap, gfx, sceneTag, ex.tag); delete snap; gfx->setScreenReadbackEnabled(false); if (ex.msaaSamples >= 0) gfx->setMsaaSamples(4); } resetRenderControl(gfx); gfx->setScreenReadbackEnabled(true); } void snapAndSave(Graphics *gfx, const char *name) { gfx->setScreenReadbackEnabled(true); warmPresent(gfx); eve::image::ImageData *snap = gfx->newImageData(); REQUIRE(snap != nullptr); saveFramePng(gfx, name, snap); delete snap; gfx->setScreenReadbackEnabled(false); } /** Isolate the pbr_chart ground disk: hide spheres, dump lighting vs GBuffer normals. */ void dumpPbrChartGroundDiag(Graphics *gfx, SceneActors &actors, const CamKey &cam) { REQUIRE(!actors.ents.empty()); applyCam(actors.cam, cam); applyConfig(actors, RenderCfg::DirectionalShadows, false); snapAndSave(gfx, "pbr_chart_diag_shadows.png"); CamKey side = cam; side.eyeX = 2.f * cam.tgtX - cam.eyeX; side.eyeZ = 2.f * cam.tgtZ - cam.eyeZ; applyCam(actors.cam, side); snapAndSave(gfx, "pbr_chart_diag_shadows_side.png"); applyCam(actors.cam, cam); for (size_t i = 1; i < actors.ents.size(); ++i) actors.ents[i]->setVisible(false); auto *ground = actors.ents[0]; const float savedRough = ground->meshRenderer()->roughness; applyConfig(actors, RenderCfg::DirectionalLit, false); snapAndSave(gfx, "pbr_chart_diag_ground_dir.png"); applyConfig(actors, RenderCfg::IblReflections, false); snapAndSave(gfx, "pbr_chart_diag_ground_ibl.png"); applyConfig(actors, RenderCfg::DirectionalLit, false); ground->setRoughness(1.f); snapAndSave(gfx, "pbr_chart_diag_ground_matte.png"); ground->setRoughness(savedRough); RenderControl *rc = gfx->getRenderControl(); REQUIRE(rc != nullptr); applyConfig(actors, RenderCfg::DirectionalLit, false); resetRenderControl(gfx); rc->enable("gbuffer"); rc->compile(); gfx->setScreenReadbackEnabled(false); for (int i = 0; i < 2; ++i) { RenderSystem3D::render(*gfx); RenderSystem::render(*gfx); } Texture *nTex = rc->getGBuffer()->getNormalTexture(); REQUIRE(nTex != nullptr); gfx->setScreenReadbackEnabled(true); gfx->setBackgroundColorRGBA(0.08f, 0.09f, 0.11f, 1.f); gfx->clearScreen(); gfx->drawTexturedRectRGBA(nTex, 0, 0, float(gfx->getWidth()), float(gfx->getHeight()), 1, 1, 1, 1); gfx->present(); eve::image::ImageData *nSnap = gfx->newImageData(); REQUIRE(nSnap != nullptr); saveFramePng(gfx, "pbr_chart_diag_gbuffer_n.png", nSnap); delete nSnap; for (auto *e : actors.ents) e->setVisible(true); resetRenderControl(gfx); gfx->setScreenReadbackEnabled(true); } void dumpCornellShadowSweep(Graphics *gfx, SceneActors &actors, const std::vector &path) { REQUIRE(path.size() >= 2); applyConfig(actors, RenderCfg::DirectionalShadows, false); for (size_t i = 0; i < path.size(); ++i) { applyCam(actors.cam, path[i]); char name[80]; std::snprintf(name, sizeof(name), "cornell_diag_shadow_k%zu.png", i); snapAndSave(gfx, name); } // Consecutive micro-steps on the first leg: swimming shows up as the umbra // crawling across the floor between otherwise-similar frames. for (int i = 0; i < 6; ++i) { applyCam(actors.cam, lerpKey(path[0], path[1], float(i) * 0.04f)); char name[80]; std::snprintf(name, sizeof(name), "cornell_diag_shadow_step%02d.png", i); snapAndSave(gfx, name); } } float meanLuma(Graphics *gfx, int step = 8) { const int w = gfx->getWidth(); const int h = gfx->getHeight(); // Center crop — background clear color otherwise dilutes small/dark assets. const int x0 = w / 4; const int x1 = (w * 3) / 4; const int y0 = h / 4; const int y1 = (h * 3) / 4; double sum = 0.0; int n = 0; for (int y = y0; y < y1; y += step) { for (int x = x0; x < x1; x += step) { sum += luma(gfx->getPixel(x, y)); ++n; } } return n ? float(sum / n) : 0.f; } /** Upload an Assimp mesh with a baked world transform (copies positions/normals). */ Mesh *uploadMeshWorld(Graphics *gfx, const aiMesh *src, const aiMatrix4x4 &world) { REQUIRE(src != nullptr); return gfx->newMeshFromAssimp(*src, world); } /** Spawn every Assimp mesh with node transforms baked + material tint/texture. * @param texturesLoaded optional out-counter for successfully decoded maps. * @param useMaterialPbrFactors when false, keep caller metallic/roughness defaults * (glTF's metallicFactor default is 1.0; without sampling the MR texture that * turns dielectrics into black mirrors under direct light). */ int spawnModel(Graphics *gfx, eve::model3d::ModelData *md, const std::string &assetDir, SceneActors &out, float defaultMetallic = 0.05f, float defaultRoughness = 0.55f, int *texturesLoaded = nullptr, bool useMaterialPbrFactors = true) { REQUIRE(md != nullptr); const aiScene *scene = md->getScene(); REQUIRE(scene != nullptr); REQUIRE(scene->mRootNode != nullptr); Texture *white = makeSolid(gfx, 230, 230, 230); struct MatLook { Texture *tex = nullptr; float tr = 1.f, tg = 1.f, tb = 1.f; float metallic = 0.05f; float roughness = 0.55f; }; std::unordered_map matCache; auto resolveMat = [&](unsigned matIndex) -> MatLook { auto cached = matCache.find(matIndex); if (cached != matCache.end()) return cached->second; MatLook look{white, 1.f, 1.f, 1.f, defaultMetallic, defaultRoughness}; 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); float metallic = defaultMetallic; float roughness = defaultRoughness; if (useMaterialPbrFactors) { mat->Get(AI_MATKEY_METALLIC_FACTOR, metallic); mat->Get(AI_MATKEY_ROUGHNESS_FACTOR, roughness); // If a metallic-roughness map exists, factors alone are not enough — // fall back to dielectrics so architecture stays lit without IBL. aiString mrPath; if (mat->GetTexture(aiTextureType_UNKNOWN, 0, &mrPath) == AI_SUCCESS || mat->GetTexture(aiTextureType_METALNESS, 0, &mrPath) == AI_SUCCESS || mat->GetTexture(aiTextureType_DIFFUSE_ROUGHNESS, 0, &mrPath) == AI_SUCCESS) { metallic = defaultMetallic; roughness = defaultRoughness; } } // Prefer BASE_COLOR factor when present (glTF). aiColor4D base(kd.r, kd.g, kd.b, 1.f); if (mat->Get(AI_MATKEY_BASE_COLOR, base) == AI_SUCCESS) { kd.r = base.r; kd.g = base.g; kd.b = base.b; } Texture *fromMat = loadAssimpDiffuseTexture(gfx, scene, mat, assetDir); if (fromMat) { if (fromMat->getWidth() > 1 || fromMat->getHeight() > 1) { REQUIRE(fromMat->getMipmapCount() > 1); REQUIRE(fromMat->getSampler().maxAnisotropy > 1.f); // Cast enums: zeroerr cannot stream MipmapMode for REQUIRE diagnostics. REQUIRE(static_cast(fromMat->getSampler().mipmap) == static_cast(MipmapMode::Linear)); } look.tex = fromMat; look.tr = kd.r; look.tg = kd.g; look.tb = kd.b; if (texturesLoaded) ++(*texturesLoaded); } else { look.tex = makeSolid(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; } look.metallic = metallic; look.roughness = roughness; } matCache[matIndex] = look; return look; }; 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); MatLook look = resolveMat(ai->mMaterialIndex); auto *ent = Renderable3D::create(); ent->meshRenderer()->mesh = mesh; ent->meshRenderer()->texture = look.tex; ent->meshRenderer()->visible = true; ent->setTint(look.tr, look.tg, look.tb, 1.f); ent->setMetallic(look.metallic); ent->setRoughness(look.roughness); ent->setCastShadow(true); ent->setReceiveShadow(true); out.ents.push_back(ent); ++spawned; } for (unsigned c = 0; c < node->mNumChildren; ++c) walk(node->mChildren[c], world); }; walk(scene->mRootNode, aiMatrix4x4()); out.bounds = boundsOfScene(scene); return spawned; } /** Procedural metal/roughness chart on XZ — classic material-response test. */ void spawnMetalRoughnessChart(Graphics *gfx, SceneActors &out) { Mesh *sphere = gfx->newMeshSphere(24, 16); REQUIRE(sphere != nullptr); // Ground plane so shadows have somewhere to land. const float sphereR = 0.42f; const float groundHy = 0.04f; // cylinder object Y in [-1,1] * scale auto *ground = Renderable3D::create(); ground->setMesh(gfx->newMeshCylinder(32, 1, true)); ground->setTexture(makeSolid(gfx, 60, 62, 68)); ground->setPosition(0.f, -sphereR - groundHy, 0.f); ground->setScale(4.5f, groundHy, 4.5f); ground->setMetallic(0.f); ground->setRoughness(0.95f); ground->setCastShadow(false); ground->setReceiveShadow(true); out.ents.push_back(ground); out.bounds.expand(-4.f, -0.7f, -4.f); out.bounds.expand(4.f, 1.2f, 4.f); const int nM = 4; const int nR = 4; for (int im = 0; im < nM; ++im) { for (int ir = 0; ir < nR; ++ir) { auto *ent = Renderable3D::create(); ent->setMesh(sphere); ent->setTexture(makeSolid(gfx, 210, 210, 215)); const float x = (float(im) - 1.5f) * 1.15f; const float z = (float(ir) - 1.5f) * 1.15f; ent->setPosition(x, 0.f, z); ent->setScale(sphereR, sphereR, sphereR); ent->setMetallic(float(im) / float(nM - 1)); ent->setRoughness(0.08f + 0.85f * (float(ir) / float(nR - 1))); ent->setCastShadow(true); ent->setReceiveShadow(true); out.ents.push_back(ent); out.bounds.expand(x - 0.5f, -0.5f, z - 0.5f); out.bounds.expand(x + 0.5f, 0.6f, z + 0.5f); } } } eve::model3d::ModelData *loadModelFromDir(const std::string &dir, const std::string &relPath) { REQUIRE(fileExists(dir + "/" + relPath)); auto *fs = eve::filesystem::Filesystem::create(); REQUIRE(fs->setIdentity("ev_ut_classic_scenes", true)); REQUIRE(fs->setupWriteDirectory()); fs->allowMountingForPath(dir); REQUIRE(fs->mount(dir, "", false)); auto *mod = eve::model3d::Model3D::create(); return mod->newModelDataFromFile(relPath); } void setupLights(SceneActors &a, const Bounds &b) { const float cx = b.centerX(); const float cy = b.centerY(); const float cz = b.centerZ(); const float rad = std::max(0.5f, b.radius()); a.sun = Light3D::createLight("dir"); a.sun->setDirection(0.45f, 1.f, 0.35f); setSunColor(a, 1.f, 0.97f, 0.92f, 2.2f); a.sun->setCastShadow(false); a.sun->setEnabled(false); a.pointA = Light3D::createLight("point"); a.pointA->setPosition(cx - rad * 0.55f, cy + rad * 0.35f, cz + rad * 0.2f); a.pointA->setColor(1.f, 0.55f, 0.35f, 4.5f); a.pointA->setRadius(rad * 2.2f); a.pointA->setEnabled(false); a.pointB = Light3D::createLight("point"); a.pointB->setPosition(cx + rad * 0.55f, cy + rad * 0.25f, cz - rad * 0.15f); a.pointB->setColor(0.35f, 0.55f, 1.f, 4.0f); a.pointB->setRadius(rad * 2.2f); a.pointB->setEnabled(false); } std::vector makeOrbitPath(const Bounds &b, float elev = 0.35f, float distScale = 1.7f) { const float cx = b.centerX(); const float cy = b.centerY(); const float cz = b.centerZ(); const float rad = std::max(0.5f, b.radius()); const float dist = rad * distScale; const float height = cy + rad * elev; std::vector keys; const int n = 5; // Start at a 3/4 view (~51° from +X) so path[0] snapshots read as a grid, // not a side-on collapse of columns. const float a0 = 0.90f; for (int i = 0; i < n; ++i) { const float a = a0 + float(i) / float(n) * 6.2831853f; keys.push_back(CamKey{cx + std::cos(a) * dist, height, cz + std::sin(a) * dist, cx, cy, cz}); } keys.push_back(keys.front()); return keys; } std::vector makeCornellPath(const Bounds &b) { const float cx = b.centerX(); const float cy = b.centerY(); const float cz = b.centerZ(); return { CamKey{cx, cy + 0.15f, cz + 2.4f, cx, cy + 0.1f, cz}, CamKey{cx - 0.55f, cy + 0.55f, cz + 1.6f, cx, cy + 0.35f, cz - 0.2f}, CamKey{cx + 0.55f, cy + 0.35f, cz + 1.5f, cx, cy + 0.2f, cz - 0.1f}, CamKey{cx, cy + 0.9f, cz + 1.2f, cx, cy + 0.2f, cz}, CamKey{cx, cy + 0.15f, cz + 2.4f, cx, cy + 0.1f, cz}, }; } std::vector makeSponzaPath(const Bounds &b) { // Crytek Sponza's open atrium runs along +X; keep the eye inside the courtyard // rather than outside the facade (which reads as a black wall fill). const float x0 = b.minX + (b.maxX - b.minX) * 0.18f; const float x1 = b.minX + (b.maxX - b.minX) * 0.82f; const float yEye = b.minY + (b.maxY - b.minY) * 0.18f; const float yTgt = b.minY + (b.maxY - b.minY) * 0.16f; const float z = b.centerZ(); const float zSide = (b.maxZ - b.minZ) * 0.08f; return { CamKey{x0, yEye, z, x1, yTgt, z}, CamKey{b.centerX(), yEye, z + zSide, b.centerX() + (x1 - x0) * 0.15f, yTgt, z}, CamKey{x1, yEye * 1.05f, z, x0, yTgt, z}, CamKey{b.centerX(), b.minY + (b.maxY - b.minY) * 0.42f, z - zSide, b.centerX(), yTgt, z}, CamKey{x0, yEye, z, x1, yTgt, z}, }; } /** * Fly the camera through @p path while cycling render configs. * Presents live frames to the window and writes one FullLit PNG per scene * (`{tag}.png`). Set EVENGINE_DUMP_PHASES=1 to also write per-phase dumps. * Returns mean luma sampled at the end of each config phase. */ std::vector flyThroughConfigs(Graphics *gfx, SceneActors &actors, const std::vector &path, const char *sceneTag, bool polishMetalsForIbl) { REQUIRE(actors.cam != nullptr); REQUIRE(path.size() >= 2); REQUIRE(actors.env != nullptr); const RenderCfg phases[] = { RenderCfg::AmbientOnly, RenderCfg::DirectionalLit, RenderCfg::DirectionalShadows, RenderCfg::MultiPointLit, RenderCfg::IblReflections, }; const int nPhases = int(sizeof(phases) / sizeof(phases[0])); std::vector phaseLuma; phaseLuma.reserve(size_t(nPhases)); // Animation frames do not need CPU pixels. Enabling readback here would // wait the GPU and copy the whole swapchain every present (~10× slower). gfx->setScreenReadbackEnabled(false); gfx->setBackgroundColor(Color(0.14f, 0.15f, 0.17f, 1.f)); addHud(); auto *timer = eve::timer::Timer::create(); const float hz = viewHz(); const float secPerPhase = viewSecondsPerPhase(); const float totalSec = float(nPhases) * secPerPhase; const int legs = int(path.size()) - 1; std::printf("ClassicScenes[%s] view %.1fs/phase @ %.0f Hz (set EVENGINE_VIEW_SECONDS to change)\n", sceneTag, secPerPhase, hz); auto dumpHero = [&]() { applyCam(actors.cam, path[0]); if (std::string(sceneTag) == "pbr_chart") { for (auto *e : actors.ents) e->transform()->yaw = 0.f; } applyConfig(actors, RenderCfg::FullLit, polishMetalsForIbl); resetRenderControl(gfx); gfx->setScreenReadbackEnabled(true); warmPresent(gfx); eve::image::ImageData *hero = gfx->newImageData(); REQUIRE(hero != nullptr); saveFramePng(gfx, std::string(sceneTag) + ".png", hero); delete hero; gfx->setScreenReadbackEnabled(false); }; dumpHero(); const float tFly0 = timer->getTime(); int frame = 0; for (int pi = 0; pi < nPhases; ++pi) { applyConfig(actors, phases[pi], polishMetalsForIbl && cfgUsesIbl(phases[pi])); std::printf("ClassicScenes[%s] phase=%s\n", sceneTag, cfgName(phases[pi])); const float phaseEnd = float(pi + 1) * secPerPhase; const float tPhase0 = timer->getTime(); int nFrames = 0; const int frameCap = std::max(8, int(hz * secPerPhase) * 4); while (nFrames < frameCap) { const float now = timer->getTime(); const float elapsed = now - tFly0; if (elapsed >= phaseEnd && nFrames >= 2) break; const float tPath = totalSec > 1e-4f ? std::min(elapsed / totalSec, 1.f) : 0.f; const float legF = tPath * float(legs); const int leg = std::min(legs - 1, int(legF)); const float u = legF - float(leg); applyCam(actors.cam, lerpKey(path[size_t(leg)], path[size_t(leg + 1)], u)); if (std::string(sceneTag) == "pbr_chart") { for (auto *e : actors.ents) e->transform()->yaw = float(frame) * 0.03f; } RenderSystem3D::render(*gfx); RenderSystem::render(*gfx); SDL_Event e; while (SDL_PollEvent(&e)) { if (e.type == SDL_QUIT) break; } paceViewFrame(timer, now, hz); ++nFrames; ++frame; } const float phaseDt = timer->getTime() - tPhase0; std::printf("ClassicScenes[%s] phase=%s view %d frames in %.2fs (%.1f fps)\n", sceneTag, cfgName(phases[pi]), nFrames, phaseDt, phaseDt > 1e-4f ? float(nFrames) / phaseDt : 0.f); // Snapshot a front-on / path-start key so charts and interiors stay // readable. The fly still covers the full path for live viewing. applyCam(actors.cam, path[0]); if (std::string(sceneTag) == "pbr_chart") { for (auto *e : actors.ents) e->transform()->yaw = 0.f; } gfx->setScreenReadbackEnabled(true); warmPresent(gfx); const float L = meanLuma(gfx); phaseLuma.push_back(L); eve::image::ImageData *snap = gfx->newImageData(); REQUIRE(snap != nullptr); if (dumpPhaseFrames()) saveFramePng(gfx, std::string(sceneTag) + "_" + cfgName(phases[pi]) + ".png", snap); auditCapturedFrame(snap, gfx, sceneTag, cfgName(phases[pi])); delete snap; std::printf("ClassicScenes[%s] phase=%s meanLuma=%.4f\n", sceneTag, cfgName(phases[pi]), L); gfx->setScreenReadbackEnabled(false); } dumpHero(); // Callers (cornell wall sampling) may read pixels after we return. gfx->setScreenReadbackEnabled(true); return phaseLuma; } struct FpsResult { int frames = 0; double seconds = 0.0; double avgFps = 0.0; double minFps = 0.0; double maxFps = 0.0; }; int perfTimedFrames() { const char *env = std::getenv("EVENGINE_PERF_FRAMES"); if (!env || !env[0]) return 120; const int n = std::atoi(env); return n > 8 ? n : 8; } FpsResult measureFps(Graphics *gfx, SceneActors &actors, const std::vector &path, RenderCfg cfg, bool polishMetals, int warmupFrames, int timedFrames) { REQUIRE(actors.cam != nullptr); REQUIRE(path.size() >= 2); applyConfig(actors, cfg, polishMetals && cfgUsesIbl(cfg)); gfx->setScreenReadbackEnabled(false); const int legs = int(path.size()) - 1; auto renderOne = [&](int f, int total) { const float tPath = (total <= 1) ? 0.f : float(f) / float(total - 1); const float legF = tPath * float(legs); const int leg = std::min(legs - 1, int(legF)); const float u = legF - float(leg); applyCam(actors.cam, lerpKey(path[size_t(leg)], path[size_t(leg + 1)], u)); RenderSystem3D::render(*gfx); RenderSystem::render(*gfx); SDL_Event e; while (SDL_PollEvent(&e)) { } }; for (int i = 0; i < warmupFrames; ++i) renderOne(i, warmupFrames); using Clock = std::chrono::steady_clock; double maxDt = 0.0; double minDt = 1e9; const auto t0 = Clock::now(); auto prev = t0; for (int i = 0; i < timedFrames; ++i) { renderOne(i, timedFrames); const auto now = Clock::now(); const double dt = std::chrono::duration(now - prev).count(); prev = now; if (dt > maxDt) maxDt = dt; if (dt > 0.0 && dt < minDt) minDt = dt; } const double elapsed = std::chrono::duration(Clock::now() - t0).count(); FpsResult r; r.frames = timedFrames; r.seconds = elapsed; r.avgFps = (elapsed > 0.0) ? double(timedFrames) / elapsed : 0.0; r.minFps = (maxDt > 0.0) ? 1.0 / maxDt : 0.0; r.maxFps = (minDt > 0.0 && minDt < 1e9) ? 1.0 / minDt : r.avgFps; return r; } void printFps(const char *sceneTag, RenderCfg cfg, const FpsResult &r) { std::printf("ClassicScenes.perf[%s] phase=%-12s avg=%7.1f fps min=%6.1f peak=%7.1f " "(%d frames in %.3fs)\n", sceneTag, cfgName(cfg), r.avgFps, r.minFps, r.maxFps, r.frames, r.seconds); } double benchAllConfigs(Graphics *gfx, SceneActors &actors, const std::vector &path, const char *sceneTag, bool polishMetals) { gfx->setVSync(false); gfx->setScreenReadbackEnabled(false); gfx->setBackgroundColor(Color(0.14f, 0.15f, 0.17f, 1.f)); const RenderCfg phases[] = { RenderCfg::AmbientOnly, RenderCfg::DirectionalLit, RenderCfg::DirectionalShadows, RenderCfg::MultiPointLit, RenderCfg::IblReflections, }; const int warmup = 20; const int timed = perfTimedFrames(); double bestAvg = 0.0; for (RenderCfg cfg : phases) { const FpsResult r = measureFps(gfx, actors, path, cfg, polishMetals, warmup, timed); printFps(sceneTag, cfg, r); bestAvg = std::max(bestAvg, r.avgFps); } std::printf("ClassicScenes.perf[%s] best_avg=%.1f fps vsync=%d %dx%d\n", sceneTag, bestAvg, gfx->isVSync() ? 1 : 0, gfx->getWidth(), gfx->getHeight()); return bestAvg; } double benchGltfOrbitScene(Graphics *gfx, const char *relDir, const char *gltfFile, const char *tag, float defaultMetallic, float defaultRoughness, bool usePbrFactors, bool polishMetalsForIbl, float elev, float distScale, int minMeshes, int minTextures) { const std::string dir = pathBesideThisSource(relDir); if (!fileExists(dir + "/" + gltfFile)) { std::printf("ClassicScenes.perf[%s]: skip (missing %s/%s)\n", tag, dir.c_str(), gltfFile); return 0.0; } eve::model3d::ModelData *md = nullptr; try { resetScene3D(); md = loadModelFromDir(dir, gltfFile); REQUIRE(md != nullptr); SceneActors actors; int texLoaded = 0; REQUIRE(spawnModel(gfx, md, dir, actors, defaultMetallic, defaultRoughness, &texLoaded, usePbrFactors) >= minMeshes); REQUIRE(actors.bounds.valid); if (minTextures > 0) REQUIRE(texLoaded >= minTextures); const float rad0 = std::max(1e-4f, actors.bounds.radius()); const float targetRad = 2.0f; if (std::fabs(rad0 - targetRad) > 0.15f) { const float s = targetRad / rad0; const float cx = actors.bounds.centerX(); const float cy = actors.bounds.centerY(); const float cz = actors.bounds.centerZ(); for (auto *e : actors.ents) { auto xf = e->transform(); xf->x = cx + (xf->x - cx) * s; xf->y = cy + (xf->y - cy) * s; xf->z = cz + (xf->z - cz) * s; xf->sx *= s; xf->sy *= s; xf->sz *= s; } Bounds nb; nb.expand(cx - targetRad, cy - targetRad, cz - targetRad); nb.expand(cx + targetRad, cy + targetRad, cz + targetRad); actors.bounds = nb; } std::printf("ClassicScenes.perf[%s] meshes=%zu textures=%d radius=%.3f\n", tag, actors.ents.size(), texLoaded, actors.bounds.radius()); actors.cam = Camera3D::createCamera(); const float rad = std::max(0.5f, actors.bounds.radius()); actors.cam->data()->nearZ = std::max(0.05f, rad * 0.01f); actors.cam->data()->farZ = std::max(40.f, rad * 25.f); actors.env = makeStudioCubemap(gfx); setupLights(actors, actors.bounds); setSunColor(actors, 1.f, 0.97f, 0.92f, 3.5f); actors.pointA->setColor(1.f, 0.55f, 0.35f, 7.f); actors.pointB->setColor(0.35f, 0.55f, 1.f, 6.5f); auto path = makeOrbitPath(actors.bounds, elev, distScale); const double best = benchAllConfigs(gfx, actors, path, tag, polishMetalsForIbl); return best; } catch (const std::exception &ex) { std::printf("ClassicScenes.perf[%s]: skip after error: %s\n", tag, ex.what()); return 0.0; } } void expectLightingResponse(const std::vector &L) { REQUIRE(L.size() >= 5); // Soft metals (DamagedHelmet etc.) can stay dark / slightly darker under // directional-only because diffuse is tiny; accept either a directional lift // or a clear IBL response over ambient. const bool dirResponds = L[1] > L[0] + 0.004f; const bool iblResponds = L[4] > L[0] + 0.02f; const bool lightingOk = dirResponds || iblResponds; REQUIRE(lightingOk); // Multi-point and IBL should produce non-black frames. REQUIRE(L[3] > 0.02f); REQUIRE(L[4] > 0.02f); } } // namespace // Defined below; forward-declared so early ClassicScenes cases can soft-skip via it. bool runGltfOrbitScene(const char *relDir, const char *gltfFile, const char *tag, float defaultMetallic, float defaultRoughness, bool usePbrFactors, bool polishMetalsForIbl, float elev = 0.35f, float distScale = 1.7f, int minMeshes = 1, int minTextures = 0, int winW = 640, int winH = 480); TEST_CASE("ClassicScenes.cornell.flythroughConfigs") { const std::string dir = pathBesideThisSource("assets/classic/cornell"); const std::string obj = "CornellBox-Original.obj"; REQUIRE(fileExists(dir + "/" + obj)); eve::window::Window *win = nullptr; Graphics *gfx = nullptr; openGfxWindow(win, gfx); resetScene3D(); auto *md = loadModelFromDir(dir, obj); REQUIRE(md != nullptr); SceneActors actors; REQUIRE(spawnModel(gfx, md, dir, actors, 0.02f, 0.75f) >= 1); REQUIRE(actors.bounds.valid); actors.cam = Camera3D::createCamera(); actors.cam->data()->nearZ = 0.05f; actors.cam->data()->farZ = 50.f; actors.env = makeStudioCubemap(gfx); setupLights(actors, actors.bounds); // Ceiling-ish sun for the box. actors.sun->setDirection(0.15f, 1.f, 0.05f); setSunColor(actors, 1.f, 0.97f, 0.92f, 2.8f); actors.pointA->setPosition(actors.bounds.centerX(), actors.bounds.maxY - 0.15f, actors.bounds.centerZ()); actors.pointA->setColor(1.f, 1.f, 0.95f, 6.f); actors.pointA->setRadius(4.f); auto path = makeCornellPath(actors.bounds); auto L = flyThroughConfigs(gfx, actors, path, "cornell", /*polishMetalsForIbl=*/true); expectLightingResponse(L); snapStaticConfigGrid(gfx, actors, "cornell", /*polishMetals=*/true); if (dumpPhaseFrames()) dumpCornellShadowSweep(gfx, actors, path); // Red vs green walls should remain distinguishable under directional light. applyConfig(actors, RenderCfg::DirectionalLit, false); applyCam(actors.cam, path[0]); warmPresent(gfx); const int w = gfx->getWidth(); const int h = gfx->getHeight(); const Color left = gfx->getPixel(w / 5, h / 2); const Color right = gfx->getPixel((w * 4) / 5, h / 2); CHECK(left.r + 0.02f > left.g); // left wall tends red CHECK(right.g + 0.01f > right.r); // right wall tends green // Orientation: short box sits on the floor — lower third of the frame should // carry more of the box silhouette than the upper third (ceiling). const float lower = meanLuma(gfx); // reused warm frame (void)lower; double sumTop = 0.0, sumBot = 0.0; int nTop = 0, nBot = 0; for (int y = 0; y < h / 4; y += 4) { for (int x = w / 3; x < (w * 2) / 3; x += 4) { sumTop += luma(gfx->getPixel(x, y)); ++nTop; } } for (int y = (h * 3) / 4; y < h; y += 4) { for (int x = w / 3; x < (w * 2) / 3; x += 4) { sumBot += luma(gfx->getPixel(x, y)); ++nBot; } } const float topL = nTop ? float(sumTop / nTop) : 0.f; const float botL = nBot ? float(sumBot / nBot) : 0.f; std::printf("ClassicScenes[cornell] orientation topL=%.3f botL=%.3f\n", topL, botL); // Floor region (bottom) must not be empty/black while ceiling (top) is lit alone — // both contribute under directional light when the camera looks into the box. REQUIRE(botL > 0.02f); REQUIRE(topL > 0.01f); win->close(); } TEST_CASE("ClassicScenes.pbrChart.flythroughConfigs") { eve::window::Window *win = nullptr; Graphics *gfx = nullptr; openGfxWindow(win, gfx); resetScene3D(); SceneActors actors; spawnMetalRoughnessChart(gfx, actors); REQUIRE(actors.ents.size() == 17); // 16 spheres + ground REQUIRE(actors.bounds.valid); actors.cam = Camera3D::createCamera(); actors.cam->data()->nearZ = 0.05f; actors.cam->data()->farZ = 40.f; actors.env = makeStudioCubemap(gfx); setupLights(actors, actors.bounds); setSunColor(actors, 1.f, 0.97f, 0.92f, 3.2f); // Glancing sun so sphere umbrae stretch across the disk in path[0] (a high // sun hides them behind the spheres). actors.sun->setDirection(0.9f, 0.45f, 0.25f); auto path = makeOrbitPath(actors.bounds, 0.55f, 1.65f); auto L = flyThroughConfigs(gfx, actors, path, "pbr_chart", /*polishMetalsForIbl=*/false); expectLightingResponse(L); snapStaticConfigGrid(gfx, actors, "pbr_chart", /*polishMetals=*/false); if (dumpPhaseFrames()) dumpPbrChartGroundDiag(gfx, actors, path[0]); // IBL phase should brighten metals relative to ambient-only. CHECK(L[4] > L[0] + 0.01f); win->close(); } TEST_CASE("ClassicScenes.sponza.flythroughConfigs") { const std::string dir = pathBesideThisSource("assets/classic/sponza/glTF"); const std::string gltf = "Sponza.gltf"; if (!fileExists(dir + "/" + gltf)) { std::printf( "ClassicScenes.sponza: missing %s — run scripts/download_classic_scenes.sh " "(or cmake --build --target download_classic_scenes)\n", (dir + "/" + gltf).c_str()); return; } eve::window::Window *win = nullptr; Graphics *gfx = nullptr; openGfxWindow(win, gfx, 960, 540); resetScene3D(); auto *md = loadModelFromDir(dir, gltf); REQUIRE(md != nullptr); REQUIRE(md->getMeshCount() >= 10); SceneActors actors; int texLoaded = 0; REQUIRE(spawnModel(gfx, md, dir, actors, 0.02f, 0.7f, &texLoaded, /*useMaterialPbrFactors=*/false) >= 10); REQUIRE(actors.bounds.valid); std::printf( "ClassicScenes[sponza] bounds center=(%.2f,%.2f,%.2f) radius=%.2f meshes=%zu textures=%d\n", actors.bounds.centerX(), actors.bounds.centerY(), actors.bounds.centerZ(), actors.bounds.radius(), actors.ents.size(), texLoaded); // Ensure we actually bound albedo maps — otherwise the atrium stays pitch black. REQUIRE(texLoaded >= 5); actors.cam = Camera3D::createCamera(); const float rad = std::max(1.f, actors.bounds.radius()); actors.cam->data()->nearZ = std::max(0.05f, rad * 0.002f); actors.cam->data()->farZ = std::max(60.f, rad * 4.f); actors.env = makeStudioCubemap(gfx); setupLights(actors, actors.bounds); actors.sun->setDirection(0.25f, 1.f, 0.2f); setSunColor(actors, 1.f, 0.98f, 0.94f, 3.2f); actors.pointA->setColor(1.f, 0.75f, 0.45f, 7.f); actors.pointA->setRadius(rad * 1.2f); actors.pointB->setColor(0.45f, 0.65f, 1.f, 6.f); actors.pointB->setRadius(rad * 1.2f); auto path = makeSponzaPath(actors.bounds); auto L = flyThroughConfigs(gfx, actors, path, "sponza", /*polishMetalsForIbl=*/false); expectLightingResponse(L); // Shadows should not black out the whole atrium vs plain directional. REQUIRE(L[2] > 0.02f); REQUIRE(std::abs(L[2] - L[1]) < 0.45f); win->close(); } TEST_CASE("ClassicScenes.damagedHelmet.flythroughConfigs") { // Keep dielectric-friendly defaults: without sampling the MR map, glTF's // metallicFactor≈1 turns the helmet into a near-black mirror under dir-only. if (!runGltfOrbitScene("assets/classic/damaged_helmet/glTF", "DamagedHelmet.gltf", "helmet", /*defaultMetallic=*/0.05f, /*defaultRoughness=*/0.4f, /*usePbrFactors=*/false, /*polishMetalsForIbl=*/false, 0.25f, 1.8f)) { return; } } /** Soft-skip helper for downloaded Khronos glTF sample scenes. */ bool runGltfOrbitScene(const char *relDir, const char *gltfFile, const char *tag, float defaultMetallic, float defaultRoughness, bool usePbrFactors, bool polishMetalsForIbl, float elev, float distScale, int minMeshes, int minTextures, int winW, int winH) { const std::string dir = pathBesideThisSource(relDir); if (!fileExists(dir + "/" + gltfFile)) { std::printf("ClassicScenes.%s: missing %s/%s — run scripts/download_classic_scenes.sh\n", tag, dir.c_str(), gltfFile); return false; } eve::window::Window *win = nullptr; Graphics *gfx = nullptr; eve::model3d::ModelData *md = nullptr; try { openGfxWindow(win, gfx, winW, winH); resetScene3D(); md = loadModelFromDir(dir, gltfFile); REQUIRE(md != nullptr); REQUIRE(md->getMeshCount() >= minMeshes); SceneActors actors; int texLoaded = 0; REQUIRE(spawnModel(gfx, md, dir, actors, defaultMetallic, defaultRoughness, &texLoaded, usePbrFactors) >= minMeshes); REQUIRE(actors.bounds.valid); if (minTextures > 0) REQUIRE(texLoaded >= minTextures); // Normalize asset scale so orbit framing is consistent across cm-scale and // meter-scale Khronos samples (BoomBox ~100 units, Avocado ≪ 1, etc.). const float rad0 = std::max(1e-4f, actors.bounds.radius()); const float targetRad = 2.0f; if (std::fabs(rad0 - targetRad) > 0.15f) { const float s = targetRad / rad0; const float cx = actors.bounds.centerX(); const float cy = actors.bounds.centerY(); const float cz = actors.bounds.centerZ(); for (auto *e : actors.ents) { auto xf = e->transform(); xf->x = cx + (xf->x - cx) * s; xf->y = cy + (xf->y - cy) * s; xf->z = cz + (xf->z - cz) * s; xf->sx *= s; xf->sy *= s; xf->sz *= s; } Bounds nb; nb.expand(cx - targetRad, cy - targetRad, cz - targetRad); nb.expand(cx + targetRad, cy + targetRad, cz + targetRad); actors.bounds = nb; } std::printf("ClassicScenes[%s] meshes=%zu textures=%d radius=%.3f\n", tag, actors.ents.size(), texLoaded, actors.bounds.radius()); actors.cam = Camera3D::createCamera(); const float rad = std::max(0.5f, actors.bounds.radius()); actors.cam->data()->nearZ = std::max(0.05f, rad * 0.01f); actors.cam->data()->farZ = std::max(40.f, rad * 25.f); actors.env = makeStudioCubemap(gfx); setupLights(actors, actors.bounds); // Stronger key light so textured dielectrics clear the ambient→lit delta. setSunColor(actors, 1.f, 0.97f, 0.92f, 3.5f); actors.pointA->setColor(1.f, 0.55f, 0.35f, 7.f); actors.pointB->setColor(0.35f, 0.55f, 1.f, 6.5f); auto path = makeOrbitPath(actors.bounds, elev, distScale); auto L = flyThroughConfigs(gfx, actors, path, tag, polishMetalsForIbl); expectLightingResponse(L); md = nullptr; win->close(); win = nullptr; return true; } catch (...) { if (win) win->close(); throw; } } TEST_CASE("ClassicScenes.flightHelmet.flythroughConfigs") { runGltfOrbitScene("assets/classic/flight_helmet/glTF", "FlightHelmet.gltf", "flight_helmet", /*defaultMetallic=*/0.05f, /*defaultRoughness=*/0.55f, /*usePbrFactors=*/false, /*polishMetalsForIbl=*/false, 0.35f, 1.8f, /*minMeshes=*/5, /*minTextures=*/3); } TEST_CASE("ClassicScenes.boomBox.flythroughConfigs") { runGltfOrbitScene("assets/classic/boom_box/glTF", "BoomBox.gltf", "boom_box", 0.05f, 0.45f, /*usePbrFactors=*/false, /*polishMetalsForIbl=*/false, 0.3f, 1.9f); } TEST_CASE("ClassicScenes.scifiHelmet.flythroughConfigs") { runGltfOrbitScene("assets/classic/scifi_helmet/glTF", "SciFiHelmet.gltf", "scifi_helmet", 0.05f, 0.4f, /*usePbrFactors=*/false, /*polishMetalsForIbl=*/false, 0.25f, 1.8f); } TEST_CASE("ClassicScenes.metalRoughSpheres.flythroughConfigs") { runGltfOrbitScene("assets/classic/metal_rough_spheres/glTF", "MetalRoughSpheres.gltf", "metal_rough_spheres", 0.05f, 0.5f, /*usePbrFactors=*/true, /*polishMetalsForIbl=*/false, 0.45f, 1.7f, /*minMeshes=*/1, /*minTextures=*/0); } TEST_CASE("ClassicScenes.suzanne.flythroughConfigs") { // Blender monkey — silhouette / orientation / lit response. if (!runGltfOrbitScene("assets/classic/suzanne/glTF", "Suzanne.gltf", "suzanne", 0.05f, 0.55f, /*usePbrFactors=*/false, /*polishMetalsForIbl=*/true, 0.35f, 1.9f)) { return; } } TEST_CASE("ClassicScenes.duck.flythroughConfigs") { runGltfOrbitScene("assets/classic/duck/glTF", "Duck.gltf", "duck", 0.02f, 0.65f, /*usePbrFactors=*/false, /*polishMetalsForIbl=*/false, 0.45f, 1.9f); } TEST_CASE("ClassicScenes.avocado.flythroughConfigs") { runGltfOrbitScene("assets/classic/avocado/glTF", "Avocado.gltf", "avocado", 0.02f, 0.7f, /*usePbrFactors=*/false, /*polishMetalsForIbl=*/false, 0.35f, 1.9f, /*minMeshes=*/1, /*minTextures=*/1); } TEST_CASE("ClassicScenes.waterBottle.flythroughConfigs") { runGltfOrbitScene("assets/classic/water_bottle/glTF", "WaterBottle.gltf", "water_bottle", 0.05f, 0.35f, /*usePbrFactors=*/false, /*polishMetalsForIbl=*/false, 0.4f, 1.9f); } TEST_CASE("ClassicScenes.lantern.flythroughConfigs") { runGltfOrbitScene("assets/classic/lantern/glTF", "Lantern.gltf", "lantern", 0.05f, 0.55f, /*usePbrFactors=*/false, /*polishMetalsForIbl=*/false, 0.45f, 1.85f, /*minMeshes=*/1, /*minTextures=*/1); } TEST_CASE("ClassicScenes.antiqueCamera.flythroughConfigs") { runGltfOrbitScene("assets/classic/antique_camera/glTF", "AntiqueCamera.gltf", "antique_camera", 0.08f, 0.5f, /*usePbrFactors=*/false, /*polishMetalsForIbl=*/false, 0.35f, 1.9f, /*minMeshes=*/1, /*minTextures=*/1); } TEST_CASE("ClassicScenes.cesiumMilkTruck.flythroughConfigs") { runGltfOrbitScene("assets/classic/cesium_milk_truck/glTF", "CesiumMilkTruck.gltf", "cesium_milk_truck", 0.05f, 0.6f, /*usePbrFactors=*/false, /*polishMetalsForIbl=*/false, 0.4f, 2.0f, /*minMeshes=*/2, /*minTextures=*/1); } TEST_CASE("ClassicScenes.barramundiFish.flythroughConfigs") { runGltfOrbitScene("assets/classic/barramundi_fish/glTF", "BarramundiFish.gltf", "barramundi_fish", 0.02f, 0.55f, /*usePbrFactors=*/false, /*polishMetalsForIbl=*/false, 0.3f, 1.9f, /*minMeshes=*/1, /*minTextures=*/1); } TEST_CASE("ClassicScenes.corset.flythroughConfigs") { runGltfOrbitScene("assets/classic/corset/glTF", "Corset.gltf", "corset", 0.05f, 0.5f, /*usePbrFactors=*/false, /*polishMetalsForIbl=*/false, 0.35f, 1.85f, /*minMeshes=*/1, /*minTextures=*/1); } TEST_CASE("ClassicScenes.perf.maxFps") { double globalBest = 0.0; const char *bestTag = ""; auto note = [&](const char *tag, double fps) { if (fps > globalBest) { globalBest = fps; bestTag = tag; } }; eve::window::Window *win = nullptr; Graphics *gfx = nullptr; openGfxWindow(win, gfx, 960, 540); gfx->setVSync(false); gfx->setScreenReadbackEnabled(false); auto gltf = [&](const char *relDir, const char *file, const char *tag, float met, float rough, bool pbr, bool polish, float elev, float dist, int minM, int minT) { note(tag, benchGltfOrbitScene(gfx, relDir, file, tag, met, rough, pbr, polish, elev, dist, minM, minT)); }; try { resetScene3D(); SceneActors actors; spawnMetalRoughnessChart(gfx, actors); actors.cam = Camera3D::createCamera(); actors.cam->data()->nearZ = 0.05f; actors.cam->data()->farZ = 40.f; actors.env = makeStudioCubemap(gfx); setupLights(actors, actors.bounds); note("pbr_chart", benchAllConfigs(gfx, actors, makeOrbitPath(actors.bounds, 0.55f, 1.9f), "pbr_chart", false)); } catch (const std::exception &ex) { std::printf("ClassicScenes.perf[pbr_chart]: skip after error: %s\n", ex.what()); } try { const std::string dir = pathBesideThisSource("assets/classic/cornell"); const std::string obj = "CornellBox-Original.obj"; if (fileExists(dir + "/" + obj)) { resetScene3D(); auto *md = loadModelFromDir(dir, obj); SceneActors actors; spawnModel(gfx, md, dir, actors, 0.02f, 0.75f); actors.cam = Camera3D::createCamera(); actors.cam->data()->nearZ = 0.05f; actors.cam->data()->farZ = 50.f; actors.env = makeStudioCubemap(gfx); setupLights(actors, actors.bounds); note("cornell", benchAllConfigs(gfx, actors, makeCornellPath(actors.bounds), "cornell", true)); } } catch (const std::exception &ex) { std::printf("ClassicScenes.perf[cornell]: skip after error: %s\n", ex.what()); } try { const std::string dir = pathBesideThisSource("assets/classic/sponza/glTF"); if (fileExists(dir + "/Sponza.gltf")) { resetScene3D(); auto *md = loadModelFromDir(dir, "Sponza.gltf"); SceneActors actors; int texLoaded = 0; spawnModel(gfx, md, dir, actors, 0.02f, 0.7f, &texLoaded, false); actors.cam = Camera3D::createCamera(); const float rad = std::max(1.f, actors.bounds.radius()); actors.cam->data()->nearZ = std::max(0.05f, rad * 0.002f); actors.cam->data()->farZ = std::max(60.f, rad * 4.f); actors.env = makeStudioCubemap(gfx); setupLights(actors, actors.bounds); actors.sun->setDirection(0.25f, 1.f, 0.2f); setSunColor(actors, 1.f, 0.98f, 0.94f, 6.f); note("sponza", benchAllConfigs(gfx, actors, makeSponzaPath(actors.bounds), "sponza", false)); } else { std::printf("ClassicScenes.perf[sponza]: skip (asset not downloaded)\n"); } } catch (const std::exception &ex) { std::printf("ClassicScenes.perf[sponza]: skip after error: %s\n", ex.what()); } gltf("assets/classic/damaged_helmet/glTF", "DamagedHelmet.gltf", "helmet", 0.05f, 0.4f, false, false, 0.25f, 2.1f, 1, 0); gltf("assets/classic/flight_helmet/glTF", "FlightHelmet.gltf", "flight_helmet", 0.05f, 0.55f, false, false, 0.35f, 1.8f, 5, 3); gltf("assets/classic/boom_box/glTF", "BoomBox.gltf", "boom_box", 0.05f, 0.45f, false, false, 0.3f, 1.9f, 1, 0); gltf("assets/classic/scifi_helmet/glTF", "SciFiHelmet.gltf", "scifi_helmet", 0.05f, 0.4f, false, false, 0.25f, 1.8f, 1, 0); gltf("assets/classic/metal_rough_spheres/glTF", "MetalRoughSpheres.gltf", "metal_rough_spheres", 0.05f, 0.5f, true, false, 0.45f, 1.7f, 1, 0); gltf("assets/classic/suzanne/glTF", "Suzanne.gltf", "suzanne", 0.05f, 0.55f, false, true, 0.35f, 1.9f, 1, 0); gltf("assets/classic/duck/glTF", "Duck.gltf", "duck", 0.02f, 0.65f, false, false, 0.45f, 1.9f, 1, 0); gltf("assets/classic/avocado/glTF", "Avocado.gltf", "avocado", 0.02f, 0.7f, false, false, 0.35f, 1.9f, 1, 1); gltf("assets/classic/water_bottle/glTF", "WaterBottle.gltf", "water_bottle", 0.05f, 0.35f, false, false, 0.4f, 1.9f, 1, 0); gltf("assets/classic/lantern/glTF", "Lantern.gltf", "lantern", 0.05f, 0.55f, false, false, 0.45f, 1.85f, 1, 1); gltf("assets/classic/antique_camera/glTF", "AntiqueCamera.gltf", "antique_camera", 0.08f, 0.5f, false, false, 0.35f, 1.9f, 1, 1); gltf("assets/classic/cesium_milk_truck/glTF", "CesiumMilkTruck.gltf", "cesium_milk_truck", 0.05f, 0.6f, false, false, 0.4f, 2.0f, 2, 1); gltf("assets/classic/barramundi_fish/glTF", "BarramundiFish.gltf", "barramundi_fish", 0.02f, 0.55f, false, false, 0.3f, 1.9f, 1, 1); gltf("assets/classic/corset/glTF", "Corset.gltf", "corset", 0.05f, 0.5f, false, false, 0.35f, 1.85f, 1, 1); win->close(); std::printf("ClassicScenes.perf MAX avg fps = %.1f (%s) 960x540 vsync=off Debug\n", globalBest, bestTag); REQUIRE(globalBest > 1.0); }