Files
EVEngine/test/ClassicScenes.cpp
T

1713 lines
66 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
#include "Fixtures.h"
#include <SDL2/SDL.h>
#include <assimp/material.h>
#include <assimp/matrix4x4.h>
#include <assimp/mesh.h>
#include <assimp/scene.h>
#include <assimp/texture.h>
#include <assimp/vector3.h>
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <exception>
#include <filesystem>
#include <fstream>
#include <functional>
#include <memory>
#include <string>
#include <unordered_map>
#include <vector>
#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<char> readBinaryFile(const std::string &path) {
std::ifstream in(path, std::ios::binary);
if (!in) return {};
return std::vector<char>((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
}
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<Renderable3D>() != nullptr) {
auto view = ecs::View<Renderable3D, Renderable3D::MeshRenderer>();
for (auto it = view.begin(); it != view.end(); ++it) {
auto [mr] = *it;
mr->visible = false;
}
}
if (ecs::current()->getManager<Camera3D>() != nullptr) {
auto camView = ecs::View<Camera3D, Camera3D::Data>();
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<Light3D>() != nullptr) {
auto lightView = ecs::View<Light3D, Light3D::Data>();
for (auto it = lightView.begin(); it != lightView.end(); ++it) {
auto [d] = *it;
d->enabled = false;
}
}
if (ecs::current()->getManager<Renderable2D>() != nullptr) {
auto view = ecs::View<Renderable2D, Renderable2D::Sprite>();
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<uint8_t> 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<uint8_t> 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<unsigned>(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<size_t>(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<void(const aiNode *, const aiMatrix4x4 &)> 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<Renderable3D *> 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> 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<CamKey> &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<unsigned, MatLook> 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<int>(fromMat->getSampler().mipmap) ==
static_cast<int>(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<void(const aiNode *, const aiMatrix4x4 &)> 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<CamKey> 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<CamKey> 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<CamKey> 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<CamKey> 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<float> flyThroughConfigs(Graphics *gfx, SceneActors &actors,
const std::vector<CamKey> &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<float> 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<CamKey> &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<double>(now - prev).count();
prev = now;
if (dt > maxDt) maxDt = dt;
if (dt > 0.0 && dt < minDt) minDt = dt;
}
const double elapsed = std::chrono::duration<double>(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<CamKey> &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<float> &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);
}