Adds EVENGINE_ANDROID_TEST_APP build mode: the unit_test sources are compiled into libmain.so (SDL entry) and packaged by the existing Gradle APK along with the test assets, so the full zeroerr suite can run on a device via adb logcat. - test/main.cpp: Android runner with LogcatBuf (cout/cerr -> logcat), chdir to <files>/evengine_test, SDL_Init; keeps SDL_main symbol unmangled. - test/PathBesideSource.h: shared pathBesideThisSource helper that maps __FILE__ to the on-device unpacked tree on Android. - EVTestActivity: unpacks assets/test + assets/examples to internal storage and passes an optional --testcase filter from an intent extra. - test/CMakeLists.txt: builds `main` instead of unit_test on Android, excludes desktop-only devtools tests, links Android system libs. - Makefile: build/android-debug-test, sync/android-test-assets, run/android-test-debug, log/android-test targets. - Gradle wrapper bumped to 8.5 for Java 21 compatibility.
1698 lines
66 KiB
C++
1698 lines
66 KiB
C++
#include "zeroerr/assert.h"
|
||
#include "zeroerr/unittest.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/Graphics.h"
|
||
#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 openGfxWindow(eve::window::Window *&win, Graphics *&gfx, int w = 960, int h = 540) {
|
||
win = eve::window::Window::create();
|
||
gfx = Graphics::create();
|
||
REQUIRE(win != nullptr);
|
||
REQUIRE(gfx != nullptr);
|
||
win->setGraphics(gfx);
|
||
eve::window::WindowSettings s;
|
||
s.width = w;
|
||
s.height = h;
|
||
s.centered = true;
|
||
REQUIRE(win->setWindowSettings(s));
|
||
}
|
||
|
||
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;
|
||
|
||
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;
|
||
}
|
||
|
||
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);
|
||
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 PNG snapshots per phase.
|
||
* 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;
|
||
const float tFly0 = timer->getTime();
|
||
std::printf("ClassicScenes[%s] view %.1fs/phase @ %.0f Hz (set EVENGINE_VIEW_SECONDS to change)\n",
|
||
sceneTag, secPerPhase, hz);
|
||
|
||
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);
|
||
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);
|
||
}
|
||
// 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);
|
||
delete md;
|
||
return best;
|
||
} catch (const std::exception &ex) {
|
||
std::printf("ClassicScenes.perf[%s]: skip after error: %s\n", tag, ex.what());
|
||
delete md;
|
||
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);
|
||
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);
|
||
|
||
delete md;
|
||
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);
|
||
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);
|
||
|
||
delete md;
|
||
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);
|
||
|
||
delete md;
|
||
md = nullptr;
|
||
win->close();
|
||
win = nullptr;
|
||
return true;
|
||
} catch (const std::exception &ex) {
|
||
const std::string msg = ex.what();
|
||
const bool gpuTransient = msg.find("swapchain") != std::string::npos ||
|
||
msg.find("SurfaceLost") != std::string::npos ||
|
||
msg.find("OutOfDate") != std::string::npos ||
|
||
msg.find("DeviceLost") != std::string::npos;
|
||
if (gpuTransient) {
|
||
// Chaining many large glTF scenes can exhaust device memory / lose the surface.
|
||
std::printf("ClassicScenes.%s: soft-skip after GPU/surface error: %s\n", tag,
|
||
msg.c_str());
|
||
delete md;
|
||
if (win) win->close();
|
||
return false;
|
||
}
|
||
delete md;
|
||
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));
|
||
delete md;
|
||
}
|
||
} 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));
|
||
delete md;
|
||
} 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);
|
||
}
|
||
|