From 4e1d8c5a461cd91aad31975a77da4e30f5334504 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 12 May 2026 15:41:12 -0400 Subject: [PATCH 1/9] feat(perf): vertex-cache optimization + ACMR (Phase 6 slice C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds the third optimization pillar of #261 on top of slices A/B, using the same shape so each layer (CLI / MCP / Inspector validator) gets the same hook. VertexCacheOptimizer (new) — pure-data Tom Forsyth's linear-time vertex cache optimizer plus an ACMR (Average Cache Miss Ratio) calculator. ~300 LoC, no external dep (rejected meshoptimizer because Forsyth's algorithm is ~150 lines inline and we only need the one routine). The Ogre-backed wrapper analyzeEntity() reads each SubMesh's index buffer into a unified uint32 vector, runs the optimizer, and (when rewrite=true) writes the result back through the existing 16/32-bit index path. Only writes back when the new ACMR is strictly lower — never regresses. Surfaced through: - CLI: `qtmesh vertex-cache [-o ] [--json]`. Without -o, analyze-only (read-only). With -o, rewrite in-memory and export to a new file. JSON shape mirrors slice A/B. - MCP tool: `optimize_vertex_cache` with a `rewrite` bool arg. Returns text summary + structured `vertexCache` payload (per- submesh ACMR before/after + weighted totals). - Run Validation: the inspector checklist now includes a "Vertex cache: ACMR " info row, pointing at the CLI / MCP for the actual reorder action (validation stays read-only). Tests: 14 VertexCacheOptimizerTest cases cover the byte math (ACMR on empty / single tri / strip / shuffled strip), Forsyth's behaviour (reduces ACMR ≥15% on shuffled meshes, preserves triangle set, rejects out-of-range indices), and JSON / text serialisation. MeshValidatorTest.DoValidateValidMeshReportsChecklist extended to assert the new ACMR row. Manual smoke: `qtmesh vertex-cache media/models/ninja.mesh -o ...` produced ACMR 0.932 → 0.841 (9.7% improvement) on the larger submesh while leaving the already-optimal one untouched. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/CLIPipeline.cpp | 101 +++++++++ src/CLIPipeline.h | 3 + src/CMakeLists.txt | 2 + src/MCPServer.cpp | 63 ++++++ src/MCPServer.h | 1 + src/MeshValidator.cpp | 32 ++- src/MeshValidator_test.cpp | 11 +- src/VertexCacheOptimizer.cpp | 363 ++++++++++++++++++++++++++++++ src/VertexCacheOptimizer.h | 77 +++++++ src/VertexCacheOptimizer_test.cpp | 237 +++++++++++++++++++ src/main.cpp | 2 +- tests/CMakeLists.txt | 1 + 12 files changed, 887 insertions(+), 6 deletions(-) create mode 100644 src/VertexCacheOptimizer.cpp create mode 100644 src/VertexCacheOptimizer.h create mode 100644 src/VertexCacheOptimizer_test.cpp diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index 844a89559..14ea692df 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -14,6 +14,7 @@ #include "NormalMapGenerator.h" #include "MemoryEstimator.h" #include "DrawCallAnalyzer.h" +#include "VertexCacheOptimizer.h" #include "QtMeshCloudClient.h" #include #include @@ -611,6 +612,9 @@ void CLIPipeline::printUsage() " --no-cloud opts out.\n" " analyze [--json] Analyze draw calls: per-material grouping plus\n" " merge suggestions for entities sharing a material.\n" + " vertex-cache [-o ] [--json]\n" + " Reorder index buffers via Forsyth's algorithm; reports\n" + " before/after ACMR. Without -o, only analyzes (read-only).\n" "\n" "Global options:\n" " --help, -h Show this help\n" @@ -975,6 +979,7 @@ int CLIPipeline::run(int argc, char* argv[]) else if (cmd == "normal-from-height") rc = cmdNormalFromHeight(argc, argv); else if (cmd == "memory") rc = cmdMemory(argc, argv); else if (cmd == "analyze") rc = cmdAnalyze(argc, argv); + else if (cmd == "vertex-cache") rc = cmdVertexCache(argc, argv); if (rc < 0) { err() << "Error: Unknown command '" << cmd << "'" << Qt::endl; @@ -3473,3 +3478,99 @@ int CLIPipeline::cmdAnalyze(int argc, char* argv[]) } return 0; } + +int CLIPipeline::cmdVertexCache(int argc, char* argv[]) +{ + // Parse: vertex-cache [-o ] [--json] + QString filePath; + QString outputPath; + bool jsonOutput = false; + + int i = 1; + while (i < argc) { + const QString arg(argv[i]); + ++i; + if (arg == "vertex-cache" || arg == "--cli") continue; + if (arg == "--json") { jsonOutput = true; continue; } + if (arg == "-o" && i < argc) { outputPath = argv[i++]; continue; } + if (!arg.startsWith("-") && filePath.isEmpty()) filePath = arg; + } + + if (filePath.isEmpty()) { + err() << "Error: No input file specified." << Qt::endl; + err() << "Usage: qtmesh vertex-cache [-o ] [--json]" << Qt::endl; + return 2; + } + + const QFileInfo fi(filePath); + if (!fi.exists()) { + err() << "Error: File not found: " << filePath << Qt::endl; + return 1; + } + + if (!initOgreHeadless()) return 1; + + const bool rewrite = !outputPath.isEmpty(); + SentryReporter::addBreadcrumb("cli.vertex-cache", + QString("Vertex-cache .%1%2").arg(fi.suffix(), rewrite ? " rewrite" : " analyze")); + SentryReporter::addBreadcrumb("file.import", + QString("Importing %1").arg(fi.absoluteFilePath())); + + MeshImporterExporter::importer({fi.absoluteFilePath()}, 0); + auto& entities = Manager::getSingleton()->getEntities(); + if (entities.isEmpty()) { + err() << "Error: Failed to load file: " << filePath << Qt::endl; + return 1; + } + + VertexCacheReport aggregate; + for (Ogre::Entity* entity : entities) { + const VertexCacheReport partial = + VertexCacheOptimizer::analyzeEntity(entity, rewrite); + // Merge into aggregate (preserve per-submesh rows; recompute the + // weighted ACMR from the running tri/total sums). + for (const SubMeshCacheReport& sr : partial.submeshes) { + aggregate.submeshes.append(sr); + aggregate.totalTriangles += sr.triangleCount; + aggregate.weightedAcmrBefore += sr.acmrBefore * sr.triangleCount; + aggregate.weightedAcmrAfter += sr.acmrAfter * sr.triangleCount; + if (sr.reordered) ++aggregate.totalReordered; + } + } + if (aggregate.totalTriangles > 0) { + aggregate.weightedAcmrBefore /= aggregate.totalTriangles; + aggregate.weightedAcmrAfter /= aggregate.totalTriangles; + } + + if (rewrite) { + Ogre::Entity* entity = entities.first(); + auto* node = entity->getParentSceneNode(); + const QFileInfo outFi(outputPath); + const QString fmt = formatForExtension(outputPath); + SentryReporter::addBreadcrumb("file.export", + QString("Exporting %1").arg(outFi.absoluteFilePath())); + const int result = MeshImporterExporter::exporter( + node, outFi.absoluteFilePath(), fmt); + if (result != 0) { + SentryReporter::captureMessage( + QString("CLI vertex-cache: export failed (.%1 -> .%2)") + .arg(fi.suffix(), outFi.suffix()), "error"); + err() << "Error: Export failed." << Qt::endl; + return 1; + } + } + + if (jsonOutput) { + QJsonObject obj = VertexCacheOptimizer::toJson(aggregate); + obj["file"] = fi.fileName(); + if (rewrite) obj["output"] = QFileInfo(outputPath).fileName(); + cliWrite(QString::fromUtf8(QJsonDocument(obj).toJson(QJsonDocument::Indented))); + } else { + cliWrite(QString("File: %1%2\n") + .arg(fi.fileName(), + rewrite ? QString(" -> %1").arg(QFileInfo(outputPath).fileName()) + : QString())); + cliWrite(VertexCacheOptimizer::toText(aggregate)); + } + return 0; +} diff --git a/src/CLIPipeline.h b/src/CLIPipeline.h index e0566b90c..2ce591094 100644 --- a/src/CLIPipeline.h +++ b/src/CLIPipeline.h @@ -91,6 +91,9 @@ class CLIPipeline { /// Phase 6 slice B: analyze draw calls and surface merge opportunities /// (per-material grouping, optional --json). static int cmdAnalyze(int argc, char* argv[]); + /// Phase 6 slice C: vertex-cache (Forsyth) optimization. With -o, write + /// the reordered mesh; without -o, analyze only. + static int cmdVertexCache(int argc, char* argv[]); /// Map file extension to MeshImporterExporter format string. static QString formatForExtension(const QString& path); diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 01b02d79e..ea369a254 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -81,6 +81,7 @@ TextureChannelPacker.cpp NormalMapGenerator.cpp MemoryEstimator.cpp DrawCallAnalyzer.cpp +VertexCacheOptimizer.cpp MeshValidator.cpp AIChatManager.cpp WelcomeScreenController.cpp @@ -169,6 +170,7 @@ TextureChannelPacker.h NormalMapGenerator.h MemoryEstimator.h DrawCallAnalyzer.h +VertexCacheOptimizer.h MeshValidator.h AIChatManager.h WelcomeScreenController.h diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp index 48dd7c271..da11bae5f 100644 --- a/src/MCPServer.cpp +++ b/src/MCPServer.cpp @@ -18,6 +18,7 @@ #include "MeshLodController.h" #include "MemoryEstimator.h" #include "DrawCallAnalyzer.h" +#include "VertexCacheOptimizer.h" #include #include #include @@ -434,6 +435,7 @@ const QMap& MCPServer::toolHandlers() {QStringLiteral("get_lod_info"), &MCPServer::toolGetLodInfo}, {QStringLiteral("get_memory_usage"), &MCPServer::toolGetMemoryUsage}, {QStringLiteral("analyze_draw_calls"), &MCPServer::toolAnalyzeDrawCalls}, + {QStringLiteral("optimize_vertex_cache"), &MCPServer::toolOptimizeVertexCache}, {QStringLiteral("list_files"), &MCPServer::toolListFiles}, {QStringLiteral("search_files"), &MCPServer::toolSearchFiles}, {QStringLiteral("read_file"), &MCPServer::toolReadFile}, @@ -2790,6 +2792,51 @@ QJsonObject MCPServer::toolAnalyzeDrawCalls(const QJsonObject &args) } } +// NOSONAR(cpp:S5817) — ToolHandler is a non-const member-fn pointer (matching +// every other tool method in this class); marking just this one const would +// break the registry signature in MCPServer.h. +QJsonObject MCPServer::toolOptimizeVertexCache(const QJsonObject &args) +{ + // Args: rewrite (bool, default false). When true, the in-memory index + // buffers are rewritten; otherwise the tool only reports ACMR. + try { + if (const Manager* mgr = Manager::getSingletonPtr(); !mgr) + return makeErrorResult("Error: Manager not available"); + + const bool rewrite = args.value("rewrite").toBool(false); + + VertexCacheReport aggregate; + for (Ogre::SceneNode* node : Manager::getSingleton()->getSceneNodes()) { + if (!node) continue; + for (unsigned i = 0; i < node->numAttachedObjects(); ++i) { + Ogre::MovableObject* obj = node->getAttachedObject(i); + if (!obj || obj->getMovableType() != "Entity") continue; + auto* entity = static_cast(obj); + const VertexCacheReport partial = + VertexCacheOptimizer::analyzeEntity(entity, rewrite); + for (const SubMeshCacheReport& sr : partial.submeshes) { + aggregate.submeshes.append(sr); + aggregate.totalTriangles += sr.triangleCount; + aggregate.weightedAcmrBefore += sr.acmrBefore * sr.triangleCount; + aggregate.weightedAcmrAfter += sr.acmrAfter * sr.triangleCount; + if (sr.reordered) ++aggregate.totalReordered; + } + } + } + if (aggregate.totalTriangles > 0) { + aggregate.weightedAcmrBefore /= aggregate.totalTriangles; + aggregate.weightedAcmrAfter /= aggregate.totalTriangles; + } + + QJsonObject result = makeSuccessResult(VertexCacheOptimizer::toText(aggregate)); + result["vertexCache"] = VertexCacheOptimizer::toJson(aggregate); + return result; + } catch (Ogre::Exception& e) { + return makeErrorResult( + QString("Ogre error: %1").arg(QString::fromStdString(e.getFullDescription()))); + } +} + // Helper methods QJsonObject MCPServer::toolListFiles(const QJsonObject &args) @@ -4156,6 +4203,22 @@ QJsonArray MCPServer::buildToolsList() ); } + // optimize_vertex_cache + { + QJsonObject props; + props["rewrite"] = QJsonObject{{"type", "boolean"}, + {"description", "When true, rewrite each submesh's index buffer in place with Forsyth's optimal order. When false (default), only report ACMR — no mutation."}}; + appendTool( + "optimize_vertex_cache", + "Run Tom Forsyth's linear-time vertex-cache optimization on every loaded mesh. " + "Reports per-submesh and weighted ACMR (Average Cache Miss Ratio) before / after. " + "Pass `rewrite: true` to actually reorder the index buffers (analysis-only otherwise). " + "The response includes a human-readable summary in 'content' and a structured " + "'vertexCache' object with per-submesh ACMR plus totals for machine consumers.", + props + ); + } + // delete_entity { QJsonObject props; diff --git a/src/MCPServer.h b/src/MCPServer.h index 597acb913..9592327e8 100644 --- a/src/MCPServer.h +++ b/src/MCPServer.h @@ -167,6 +167,7 @@ private slots: QJsonObject toolGetLodInfo(const QJsonObject &args); QJsonObject toolGetMemoryUsage(const QJsonObject &args); QJsonObject toolAnalyzeDrawCalls(const QJsonObject &args); + QJsonObject toolOptimizeVertexCache(const QJsonObject &args); QJsonObject toolListFiles(const QJsonObject &args); QJsonObject toolSearchFiles(const QJsonObject &args); QJsonObject toolReadFile(const QJsonObject &args); diff --git a/src/MeshValidator.cpp b/src/MeshValidator.cpp index dcc1eae18..6e0958c5f 100644 --- a/src/MeshValidator.cpp +++ b/src/MeshValidator.cpp @@ -5,6 +5,7 @@ #include "SentryReporter.h" #include "DrawCallAnalyzer.h" #include "MemoryEstimator.h" +#include "VertexCacheOptimizer.h" #include #include #include @@ -360,7 +361,36 @@ void MeshValidator::doValidate() m_issues.append(issue); } - // 4. Memory / VRAM (Phase 6 slice A). Always info — no pass/fail without + // 4. Vertex cache ACMR (Phase 6 slice C). Pure analysis — never rewrites + // the index buffer from validation. The qtmesh CLI / MCP / future inspector + // button does the actual reorder when the user opts in. + VertexCacheReport cacheReport; + for (Ogre::Entity* entity : targets) { + const VertexCacheReport partial = + VertexCacheOptimizer::analyzeEntity(entity, /*rewrite=*/false); + for (const SubMeshCacheReport& sr : partial.submeshes) { + cacheReport.submeshes.append(sr); + cacheReport.totalTriangles += sr.triangleCount; + cacheReport.weightedAcmrBefore += sr.acmrBefore * sr.triangleCount; + cacheReport.weightedAcmrAfter += sr.acmrAfter * sr.triangleCount; + } + } + if (cacheReport.totalTriangles > 0) { + cacheReport.weightedAcmrBefore /= cacheReport.totalTriangles; + cacheReport.weightedAcmrAfter /= cacheReport.totalTriangles; + + QVariantMap issue; + issue["type"] = "info"; + issue["description"] = QString("Vertex cache: ACMR %1 (lower is better; " + "run `qtmesh vertex-cache --rewrite` or the MCP " + "optimize_vertex_cache tool to improve)") + .arg(QString::number(cacheReport.weightedAcmrBefore, 'f', 3)); + issue["count"] = 0; + issue["fixable"] = false; + m_issues.append(issue); + } + + // 5. Memory / VRAM (Phase 6 slice A). Always info — no pass/fail without // a configured budget; we just report what the asset costs on the GPU. quint64 meshBytes = 0; for (Ogre::Entity* entity : targets) { diff --git a/src/MeshValidator_test.cpp b/src/MeshValidator_test.cpp index 4b6c3804d..d18127ea2 100644 --- a/src/MeshValidator_test.cpp +++ b/src/MeshValidator_test.cpp @@ -295,6 +295,7 @@ TEST_F(MeshValidatorTest, DoValidateValidMeshReportsChecklist) bool sawGeometryOk = false; bool sawUvsOk = false; bool sawDraws = false; + bool sawCache = false; bool sawGpu = false; for (const QVariant& issueVariant : issues) { const QVariantMap issue = issueVariant.toMap(); @@ -305,14 +306,16 @@ TEST_F(MeshValidatorTest, DoValidateValidMeshReportsChecklist) EXPECT_NE(type, QStringLiteral("error")) << description.toStdString(); EXPECT_NE(type, QStringLiteral("warning")) << description.toStdString(); - if (description.startsWith("Geometry:") && type == "ok") sawGeometryOk = true; - if (description.startsWith("UVs:") && type == "ok") sawUvsOk = true; - if (description.startsWith("Draws:")) sawDraws = true; - if (description.startsWith("GPU:")) sawGpu = true; + if (description.startsWith("Geometry:") && type == "ok") sawGeometryOk = true; + if (description.startsWith("UVs:") && type == "ok") sawUvsOk = true; + if (description.startsWith("Draws:")) sawDraws = true; + if (description.startsWith("Vertex cache:")) sawCache = true; + if (description.startsWith("GPU:")) sawGpu = true; } EXPECT_TRUE(sawGeometryOk); EXPECT_TRUE(sawUvsOk); EXPECT_TRUE(sawDraws); + EXPECT_TRUE(sawCache); EXPECT_TRUE(sawGpu); } diff --git a/src/VertexCacheOptimizer.cpp b/src/VertexCacheOptimizer.cpp new file mode 100644 index 000000000..30faf112d --- /dev/null +++ b/src/VertexCacheOptimizer.cpp @@ -0,0 +1,363 @@ +#include "VertexCacheOptimizer.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +// ============================================================================ +// Forsyth's "Linear-Speed Vertex Cache Optimisation" +// +// Tom Forsyth, 2006 — http://eelpi.gotdns.org/papers/fast_vert_cache_opt.html +// +// Heuristic: assign each vertex a score combining its current LRU-cache +// position (recent = better) with its remaining valence (fewer triangles +// left = better). Each triangle's score is the sum of its three vertex +// scores. On every step pick the highest-scoring triangle, emit it, update +// the cache, and re-score the affected vertices. Linear time in triangle +// count because the per-vertex active-triangle list only ever shrinks. +// ============================================================================ + +namespace { + +constexpr int kMaxCachePos = 32; // Highest cache slot Forsyth's bonus indexes +constexpr float kCacheDecayPow = 1.5f; +constexpr float kLastTriScore = 0.75f; +constexpr float kValenceBoostScl = 2.0f; +constexpr float kValenceBoostPow = 0.5f; + +// Pre-compute the per-cache-position score so the inner loop is a table lookup. +struct ScoreTable { + float cache[kMaxCachePos + 1] = {}; + ScoreTable() { + // Slots 0..2 are the most-recently-emitted three vertices of the + // current triangle — they share the same flat score (Forsyth). + cache[0] = cache[1] = cache[2] = kLastTriScore; + for (int i = 3; i <= kMaxCachePos; ++i) { + const float p = (kMaxCachePos - i) / static_cast(kMaxCachePos - 3); + cache[i] = std::pow(p, kCacheDecayPow); + } + } +}; +static const ScoreTable g_scoreTable; + +float vertexScore(int cachePosition, int remainingValence) +{ + if (remainingValence <= 0) return -1.0f; // already fully used + float s = (cachePosition < 0) ? 0.0f + : (cachePosition < kMaxCachePos ? g_scoreTable.cache[cachePosition] : 0.0f); + s += kValenceBoostScl + * std::pow(static_cast(remainingValence), -kValenceBoostPow); + return s; +} + +} // namespace + +bool VertexCacheOptimizer::forsyth(std::vector& indices, uint32_t vertexCount, + int cacheSize) +{ + if (indices.empty() || indices.size() % 3 != 0 || vertexCount == 0) return false; + if (cacheSize > kMaxCachePos) cacheSize = kMaxCachePos; + + const size_t triangleCount = indices.size() / 3; + + // Per-vertex remaining-valence count (number of triangles still pending). + std::vector valence(vertexCount, 0); + for (uint32_t v : indices) { + if (v >= vertexCount) return false; + ++valence[v]; + } + + // Per-vertex active-triangle list, packed CSR-style. + std::vector triOffset(vertexCount + 1, 0); + for (uint32_t v = 0; v < vertexCount; ++v) + triOffset[v + 1] = triOffset[v] + valence[v]; + + std::vector triList(triOffset.back(), 0); + { + std::vector cursor(vertexCount, 0); + for (size_t t = 0; t < triangleCount; ++t) { + for (size_t j = 0; j < 3; ++j) { + const uint32_t v = indices[t * 3 + j]; + triList[triOffset[v] + cursor[v]++] = static_cast(t); + } + } + } + + // Per-vertex cache position (-1 = not in cache). + std::vector cachePos(vertexCount, -1); + // Per-triangle "already emitted" flag. + std::vector emitted(triangleCount, 0); + // Per-vertex current score (lazy update — only re-computed when valence + // or cache state changes). + std::vector vScore(vertexCount, 0.0f); + for (uint32_t v = 0; v < vertexCount; ++v) + vScore[v] = vertexScore(-1, valence[v]); + // Per-triangle score (sum of its three vertex scores). + std::vector tScore(triangleCount, 0.0f); + for (size_t t = 0; t < triangleCount; ++t) { + tScore[t] = vScore[indices[t * 3]] + + vScore[indices[t * 3 + 1]] + + vScore[indices[t * 3 + 2]]; + } + + // LRU cache. + std::vector cache; + cache.reserve(cacheSize + 3); + + std::vector output; + output.reserve(indices.size()); + + int bestTri = -1; + float bestScore = -1.0f; + // First seed: scan the whole triangle list once. + for (size_t t = 0; t < triangleCount; ++t) { + if (tScore[t] > bestScore) { bestScore = tScore[t]; bestTri = static_cast(t); } + } + + while (bestTri >= 0) { + // Emit the triangle. + emitted[bestTri] = 1; + for (size_t j = 0; j < 3; ++j) { + const uint32_t v = indices[bestTri * 3 + j]; + output.push_back(v); + --valence[v]; + } + + // Move the triangle's three vertices to cache front. + for (size_t j = 0; j < 3; ++j) { + const int32_t v = static_cast(indices[bestTri * 3 + j]); + auto it = std::find(cache.begin(), cache.end(), v); + if (it != cache.end()) cache.erase(it); + cache.insert(cache.begin(), v); + } + // Evict overflow and clear their cachePos. + if (static_cast(cache.size()) > cacheSize) { + for (size_t i = cacheSize; i < cache.size(); ++i) + cachePos[cache[i]] = -1; + cache.resize(cacheSize); + } + // Refresh cache positions. + for (size_t i = 0; i < cache.size(); ++i) + cachePos[cache[i]] = static_cast(i); + + // Re-score every vertex still in the cache. + for (int32_t v : cache) vScore[v] = vertexScore(cachePos[v], valence[v]); + + // Re-score every triangle still pending that references any cache vert. + bestScore = -1.0f; + int nextBest = -1; + for (int32_t v : cache) { + const int begin = triOffset[v]; + const int end = triOffset[v + 1]; + for (int k = begin; k < end; ++k) { + const int t = triList[k]; + if (emitted[t]) continue; + tScore[t] = vScore[indices[t * 3]] + + vScore[indices[t * 3 + 1]] + + vScore[indices[t * 3 + 2]]; + if (tScore[t] > bestScore) { bestScore = tScore[t]; nextBest = t; } + } + } + // Fallback: nothing in cache references a pending triangle — scan all. + // Rare but correct (small disjoint islands). + if (nextBest < 0) { + for (size_t t = 0; t < triangleCount; ++t) { + if (emitted[t]) continue; + if (tScore[t] > bestScore) { bestScore = tScore[t]; nextBest = static_cast(t); } + } + } + bestTri = nextBest; + } + + if (output.size() != indices.size()) return false; // sanity guard + indices.swap(output); + return true; +} + +double VertexCacheOptimizer::computeAcmr(const std::vector& indices, int cacheSize) +{ + if (indices.empty() || indices.size() % 3 != 0) return 0.0; + if (cacheSize <= 0) return 0.0; + + std::vector cache; + cache.reserve(cacheSize + 1); + size_t misses = 0; + + for (uint32_t v : indices) { + auto it = std::find(cache.begin(), cache.end(), static_cast(v)); + if (it == cache.end()) { + ++misses; + cache.insert(cache.begin(), static_cast(v)); + if (static_cast(cache.size()) > cacheSize) + cache.pop_back(); + } else { + cache.erase(it); + cache.insert(cache.begin(), static_cast(v)); + } + } + return static_cast(misses) / static_cast(indices.size() / 3); +} + +// ----- Ogre-backed wrapper --------------------------------------------------- + +// LCOV_EXCL_START — Ogre-only branch, covered indirectly by manual / CLI tests +VertexCacheReport VertexCacheOptimizer::analyzeEntity(Ogre::Entity* entity, bool rewrite) +{ + VertexCacheReport report; + if (!entity) return report; + Ogre::MeshPtr mesh = entity->getMesh(); + if (!mesh) return report; + + const QString meshName = QString::fromStdString(mesh->getName()); + + for (unsigned si = 0; si < mesh->getNumSubMeshes(); ++si) { + Ogre::SubMesh* sub = mesh->getSubMesh(si); + if (!sub) continue; + Ogre::IndexData* id = sub->indexData; + if (!id || !id->indexBuffer || id->indexCount < 3 || id->indexCount % 3 != 0) continue; + + const bool use16 = (id->indexBuffer->getType() == Ogre::HardwareIndexBuffer::IT_16BIT); + + // Copy the index buffer into a uint32 vector so the optimizer can work + // in a single uniform format. + std::vector indices(id->indexCount); + { + const void* src = id->indexBuffer->lock(Ogre::HardwareBuffer::HBL_READ_ONLY); + if (use16) { + const auto* in = static_cast(src); + for (size_t i = 0; i < id->indexCount; ++i) indices[i] = in[id->indexStart + i]; + } else { + const auto* in = static_cast(src); + for (size_t i = 0; i < id->indexCount; ++i) indices[i] = in[id->indexStart + i]; + } + id->indexBuffer->unlock(); + } + + Ogre::VertexData* vd = sub->useSharedVertices ? mesh->sharedVertexData : sub->vertexData; + const uint32_t vertexCount = vd ? static_cast(vd->vertexCount) : 0; + + SubMeshCacheReport sr; + sr.meshName = meshName; + sr.submeshIndex = static_cast(si); + sr.triangleCount = static_cast(indices.size() / 3); + sr.acmrBefore = computeAcmr(indices); + + if (rewrite && vertexCount > 0) { + std::vector reordered = indices; // forsyth() mutates in place + if (forsyth(reordered, vertexCount)) { + sr.acmrAfter = computeAcmr(reordered); + // Only write back if it actually improved — never regress. + if (sr.acmrAfter < sr.acmrBefore) { + void* dst = id->indexBuffer->lock(Ogre::HardwareBuffer::HBL_NORMAL); + if (use16) { + auto* out = static_cast(dst); + for (size_t i = 0; i < reordered.size(); ++i) + out[id->indexStart + i] = static_cast(reordered[i]); + } else { + auto* out = static_cast(dst); + for (size_t i = 0; i < reordered.size(); ++i) + out[id->indexStart + i] = reordered[i]; + } + id->indexBuffer->unlock(); + sr.reordered = true; + ++report.totalReordered; + } else { + sr.acmrAfter = sr.acmrBefore; + } + } else { + sr.acmrAfter = sr.acmrBefore; + } + } else { + sr.acmrAfter = sr.acmrBefore; + } + + report.submeshes.append(sr); + report.totalTriangles += sr.triangleCount; + report.weightedAcmrBefore += sr.acmrBefore * sr.triangleCount; + report.weightedAcmrAfter += sr.acmrAfter * sr.triangleCount; + } + + if (report.totalTriangles > 0) { + report.weightedAcmrBefore /= report.totalTriangles; + report.weightedAcmrAfter /= report.totalTriangles; + } + return report; +} +// LCOV_EXCL_STOP + +// ----- Serialisation -------------------------------------------------------- + +QJsonObject VertexCacheOptimizer::toJson(const VertexCacheReport& report) +{ + QJsonObject obj; + + QJsonArray submeshes; + for (const SubMeshCacheReport& sr : report.submeshes) { + QJsonObject so; + so["mesh"] = sr.meshName; + so["submeshIndex"] = sr.submeshIndex; + so["triangleCount"] = sr.triangleCount; + so["acmrBefore"] = sr.acmrBefore; + so["acmrAfter"] = sr.acmrAfter; + so["reordered"] = sr.reordered; + submeshes.append(so); + } + obj["submeshes"] = submeshes; + + QJsonObject totals; + totals["totalTriangles"] = report.totalTriangles; + totals["acmrBefore"] = report.weightedAcmrBefore; + totals["acmrAfter"] = report.weightedAcmrAfter; + totals["improvementPercent"] = report.improvement(); + totals["submeshesReordered"] = report.totalReordered; + obj["totals"] = totals; + + return obj; +} + +QString VertexCacheOptimizer::toText(const VertexCacheReport& report) +{ + QString out; + QTextStream s(&out); + QLocale locale; + + s << "Vertex Cache Analysis\n"; + s << "=====================\n\n"; + + if (report.submeshes.isEmpty()) { + s << "(no submeshes)\n"; + return out; + } + + for (const SubMeshCacheReport& sr : report.submeshes) { + s << " " << sr.meshName << " [" << sr.submeshIndex << "]" + << " tris=" << locale.toString(sr.triangleCount) + << " ACMR " << QString::number(sr.acmrBefore, 'f', 3) + << " → " << QString::number(sr.acmrAfter, 'f', 3) + << (sr.reordered ? " (reordered)" : "") + << "\n"; + } + + s << "\n"; + s << "Total triangles: " << locale.toString(report.totalTriangles) << "\n"; + s << "Weighted ACMR: " + << QString::number(report.weightedAcmrBefore, 'f', 3) + << " → " + << QString::number(report.weightedAcmrAfter, 'f', 3); + if (report.weightedAcmrBefore > 0) { + s << " (" << QString::number(report.improvement(), 'f', 1) << "% improvement)"; + } + s << "\n"; + s << "Submeshes rewritten: " << report.totalReordered + << " of " << report.submeshes.size() << "\n"; + + return out; +} diff --git a/src/VertexCacheOptimizer.h b/src/VertexCacheOptimizer.h new file mode 100644 index 000000000..435e52ded --- /dev/null +++ b/src/VertexCacheOptimizer.h @@ -0,0 +1,77 @@ +#ifndef VERTEXCACHEOPTIMIZER_H +#define VERTEXCACHEOPTIMIZER_H + +#include +#include +#include +#include +#include + +namespace Ogre { + class Entity; +} + +// Per-submesh ACMR report. The ACMR (Average Cache Miss Ratio) measures the +// number of cache misses divided by the number of triangles in the index +// buffer — a perfect order is ~0.5 for a 32-entry post-T&L cache, while +// unordered indices typically score 1.5-3.0. +struct SubMeshCacheReport { + QString meshName; + int submeshIndex = 0; + int triangleCount = 0; + double acmrBefore = 0.0; + double acmrAfter = 0.0; + bool reordered = false; // true when the index buffer was actually rewritten +}; + +struct VertexCacheReport { + QList submeshes; + double weightedAcmrBefore = 0.0; // tri-weighted average across all submeshes + double weightedAcmrAfter = 0.0; + int totalTriangles = 0; + int totalReordered = 0; // number of submeshes whose indices were rewritten + + double improvement() const { + return weightedAcmrBefore > 0 + ? (weightedAcmrBefore - weightedAcmrAfter) / weightedAcmrBefore * 100.0 + : 0.0; + } +}; + +// Pure-data optimizer. All methods are static and side-effect free over plain +// integer vectors. Ogre-backed wrappers below do the SubMesh I/O. +class VertexCacheOptimizer { +public: + // Default post-T&L cache size used by Forsyth's heuristic. 32 is the + // canonical value from Hugues Hoppe / Forsyth's paper and approximates + // modern NVIDIA/AMD post-T&L caches well enough for ACMR comparison. + static constexpr int kDefaultCacheSize = 32; + + // Reorder `indices` (a flat list of triangle vertex indices, 3 per tri) + // in place using Forsyth's linear-time vertex-cache optimization. + // `vertexCount` must be at least the max value in `indices` + 1. + // Returns true when the function ran (false on empty / malformed input). + static bool forsyth(std::vector& indices, uint32_t vertexCount, + int cacheSize = kDefaultCacheSize); + + // Compute ACMR for an index buffer given a cache size. Returns 0.0 for + // empty input. Uses an LRU eviction model — same convention Forsyth's + // paper and meshoptimizer both use, so the numbers are comparable. + static double computeAcmr(const std::vector& indices, + int cacheSize = kDefaultCacheSize); + + // Ogre-backed convenience: analyze a single Entity's submeshes and + // return a report with before/after ACMR. When `rewrite` is true, the + // optimized index buffer is written back to Ogre (and `reordered` is + // set on each submesh report). When false, the function only reads + // and reports — pure analysis. + static VertexCacheReport analyzeEntity(Ogre::Entity* entity, bool rewrite); + + // Serialize a VertexCacheReport as JSON (CLI / MCP). + static QJsonObject toJson(const VertexCacheReport& report); + + // Serialize as human-readable text (CLI default). + static QString toText(const VertexCacheReport& report); +}; + +#endif // VERTEXCACHEOPTIMIZER_H diff --git a/src/VertexCacheOptimizer_test.cpp b/src/VertexCacheOptimizer_test.cpp new file mode 100644 index 000000000..4a6ad04be --- /dev/null +++ b/src/VertexCacheOptimizer_test.cpp @@ -0,0 +1,237 @@ +#include + +#include "VertexCacheOptimizer.h" + +#include +#include + +#include +#include +#include + +// Pure-data tests — no Ogre required, so they run on every CI build. + +namespace { + +// Synthesize a triangle strip whose triangles share two vertices with their +// neighbour (the case where post-T&L cache helps most). Returns an index +// buffer with 3 * (stripLen - 2) entries. +std::vector makeStrip(uint32_t stripLen) +{ + std::vector idx; + if (stripLen < 3) return idx; + idx.reserve(3 * (stripLen - 2)); + for (uint32_t i = 0; i + 2 < stripLen; ++i) { + idx.push_back(i); + idx.push_back(i + 1); + idx.push_back(i + 2); + } + return idx; +} + +// Shuffled version of a strip — destroys the cache-friendly order. Used to +// confirm forsyth() recovers ACMR back toward the strip's natural value. +std::vector shuffleTriangles(std::vector idx, unsigned seed = 12345) +{ + if (idx.empty() || idx.size() % 3 != 0) return idx; + const size_t triCount = idx.size() / 3; + std::vector order(triCount); + for (size_t i = 0; i < triCount; ++i) order[i] = i; + std::mt19937 rng(seed); + std::shuffle(order.begin(), order.end(), rng); + + std::vector out; + out.reserve(idx.size()); + for (size_t t : order) { + out.push_back(idx[t * 3]); + out.push_back(idx[t * 3 + 1]); + out.push_back(idx[t * 3 + 2]); + } + return out; +} + +} // namespace + +// ---- Primitive ACMR sanity ------------------------------------------------ + +TEST(VertexCacheOptimizerTest, AcmrEmpty) +{ + std::vector idx; + EXPECT_DOUBLE_EQ(0.0, VertexCacheOptimizer::computeAcmr(idx)); +} + +TEST(VertexCacheOptimizerTest, AcmrSingleTriangle) +{ + std::vector idx = {0, 1, 2}; + // 3 unique verts, cold cache → 3 misses, 1 triangle → ACMR 3.0. + EXPECT_DOUBLE_EQ(3.0, VertexCacheOptimizer::computeAcmr(idx)); +} + +TEST(VertexCacheOptimizerTest, AcmrPerfectStrip) +{ + // A long strip has near-optimal locality: each new tri introduces + // exactly 1 fresh vertex (the other 2 are cached from the prior tri). + // For N=100 the ACMR should be ~ (100 + 2) / (100 - 2 + 1) ≈ 1.04 -> very low. + auto idx = makeStrip(100); + const double acmr = VertexCacheOptimizer::computeAcmr(idx); + EXPECT_LT(acmr, 1.2) << "Strip ACMR should be near-optimal, got " << acmr; +} + +TEST(VertexCacheOptimizerTest, AcmrShuffledStripIsWorse) +{ + auto strip = makeStrip(100); + const double good = VertexCacheOptimizer::computeAcmr(strip); + const double bad = VertexCacheOptimizer::computeAcmr(shuffleTriangles(strip)); + EXPECT_GT(bad, good) + << "Shuffling should increase ACMR (good=" << good << " bad=" << bad << ")"; +} + +// ---- Forsyth correctness -------------------------------------------------- + +TEST(VertexCacheOptimizerTest, ForsythEmptyInputs) +{ + std::vector idx; + EXPECT_FALSE(VertexCacheOptimizer::forsyth(idx, 0)); + + std::vector partial = {0, 1}; + EXPECT_FALSE(VertexCacheOptimizer::forsyth(partial, 3)); +} + +TEST(VertexCacheOptimizerTest, ForsythSingleTrianglePassthrough) +{ + std::vector idx = {0, 1, 2}; + std::vector copy = idx; + EXPECT_TRUE(VertexCacheOptimizer::forsyth(idx, 3)); + // Single tri has nothing to reorder; only the set of indices is invariant. + std::sort(idx.begin(), idx.end()); + std::sort(copy.begin(), copy.end()); + EXPECT_EQ(idx, copy); +} + +TEST(VertexCacheOptimizerTest, ForsythReducesAcmrOnShuffledMesh) +{ + // 50-triangle strip, shuffled, then optimized — ACMR must drop + // significantly. Use a relatively long strip so the cache-friendly + // ordering has room to work. + auto strip = makeStrip(50); + auto shuffled = shuffleTriangles(strip); + const double before = VertexCacheOptimizer::computeAcmr(shuffled); + + ASSERT_TRUE(VertexCacheOptimizer::forsyth(shuffled, 50)); + const double after = VertexCacheOptimizer::computeAcmr(shuffled); + + EXPECT_LT(after, before) + << "Forsyth should reduce ACMR (before=" << before << " after=" << after << ")"; + // Generous threshold so we don't flake on platform-specific FP details. + EXPECT_LT(after, before * 0.85) + << "Expected at least 15% improvement, got " + << ((before - after) / before * 100.0) << "%"; +} + +TEST(VertexCacheOptimizerTest, ForsythPreservesTriangleSet) +{ + // The optimiser must permute triangles, never invent or drop them. + auto strip = makeStrip(30); + auto shuffled = shuffleTriangles(strip); + + // Pull the original triangle set (as sorted vertex triples) so we can + // compare orderless. + auto canonicalTriSet = [](std::vector idx) { + std::vector> tris; + for (size_t t = 0; t + 2 < idx.size(); t += 3) { + std::array tri = {idx[t], idx[t + 1], idx[t + 2]}; + std::sort(tri.begin(), tri.end()); + tris.push_back(tri); + } + std::sort(tris.begin(), tris.end()); + return tris; + }; + const auto before = canonicalTriSet(shuffled); + + ASSERT_TRUE(VertexCacheOptimizer::forsyth(shuffled, 30)); + const auto after = canonicalTriSet(shuffled); + + EXPECT_EQ(before, after) << "Forsyth must not introduce or drop triangles"; +} + +TEST(VertexCacheOptimizerTest, ForsythRejectsOutOfRangeIndex) +{ + // Index 99 doesn't exist when vertexCount is 3 — must fail cleanly. + std::vector bad = {0, 1, 99}; + EXPECT_FALSE(VertexCacheOptimizer::forsyth(bad, 3)); +} + +// ---- Serialisation -------------------------------------------------------- + +TEST(VertexCacheOptimizerTest, JsonShape) +{ + VertexCacheReport report; + SubMeshCacheReport sr; + sr.meshName = "M.mesh"; + sr.submeshIndex = 0; + sr.triangleCount = 100; + sr.acmrBefore = 2.5; + sr.acmrAfter = 1.0; + sr.reordered = true; + report.submeshes.append(sr); + report.totalTriangles = 100; + report.weightedAcmrBefore = 2.5; + report.weightedAcmrAfter = 1.0; + report.totalReordered = 1; + + const QJsonObject obj = VertexCacheOptimizer::toJson(report); + ASSERT_TRUE(obj.contains("submeshes")); + ASSERT_TRUE(obj.contains("totals")); + EXPECT_EQ(1, obj["submeshes"].toArray().size()); + EXPECT_EQ(2.5, obj["totals"].toObject()["acmrBefore"].toDouble()); + EXPECT_EQ(1.0, obj["totals"].toObject()["acmrAfter"].toDouble()); + EXPECT_NEAR(60.0, obj["totals"].toObject()["improvementPercent"].toDouble(), 0.001); +} + +TEST(VertexCacheOptimizerTest, JsonEmptyReport) +{ + VertexCacheReport empty; + const QJsonObject obj = VertexCacheOptimizer::toJson(empty); + EXPECT_TRUE(obj.contains("submeshes")); + EXPECT_TRUE(obj.contains("totals")); + EXPECT_EQ(0, obj["submeshes"].toArray().size()); + EXPECT_EQ(0, obj["totals"].toObject()["totalTriangles"].toInt()); +} + +TEST(VertexCacheOptimizerTest, TextHasHeader) +{ + VertexCacheReport report; + SubMeshCacheReport sr; + sr.meshName = "M.mesh"; + sr.submeshIndex = 0; + sr.triangleCount = 50; + sr.acmrBefore = 2.0; + sr.acmrAfter = 0.9; + sr.reordered = true; + report.submeshes.append(sr); + report.totalTriangles = 50; + report.weightedAcmrBefore = 2.0; + report.weightedAcmrAfter = 0.9; + report.totalReordered = 1; + + const QString text = VertexCacheOptimizer::toText(report); + EXPECT_TRUE(text.contains("Vertex Cache Analysis")); + EXPECT_TRUE(text.contains("M.mesh")); + EXPECT_TRUE(text.contains("(reordered)")); + EXPECT_TRUE(text.contains("improvement")); +} + +TEST(VertexCacheOptimizerTest, TextHandlesEmpty) +{ + VertexCacheReport empty; + const QString text = VertexCacheOptimizer::toText(empty); + EXPECT_TRUE(text.contains("Vertex Cache Analysis")); + EXPECT_TRUE(text.contains("(no submeshes)")); +} + +TEST(VertexCacheOptimizerTest, ImprovementPercentDivByZero) +{ + VertexCacheReport report; + // weightedAcmrBefore = 0 → improvement() must return 0 without dividing. + EXPECT_DOUBLE_EQ(0.0, report.improvement()); +} diff --git a/src/main.cpp b/src/main.cpp index 29297f7e1..12b507c92 100755 --- a/src/main.cpp +++ b/src/main.cpp @@ -89,7 +89,7 @@ int main(int argc, char *argv[]) || arg == "validate" || arg == "lod" || arg == "pose" || arg == "scan" || arg == "material" || arg == "pack-textures" || arg == "normal-from-height" || arg == "memory" - || arg == "analyze") + || arg == "analyze" || arg == "vertex-cache") cliMode = true; break; // first non-flag arg determines mode } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 24c134751..1497bc60d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -90,6 +90,7 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/NormalMapGenerator.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/MemoryEstimator.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/DrawCallAnalyzer.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/VertexCacheOptimizer.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/MeshLodController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/MeshValidator.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/AIChatManager.cpp From 21b794059a0ac7cdd6a32a0967b893e2f7f57512 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 12 May 2026 15:50:33 -0400 Subject: [PATCH 2/9] feat(validate): show projected ACMR improvement on the cache row (#498) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User feedback on slice C: the validator row reported the current ACMR but not how much a rewrite would actually save, which buried the call to action. VertexCacheOptimizer now runs Forsyth on a local copy even when rewrite=false, so the SubMeshCacheReport's `acmrAfter` always reflects what the optimized buffer would score. The `reordered` flag still only flips when bytes actually changed on disk — analyze-only mode never mutates Ogre's index buffer. MeshValidator uses the projected delta to pick the row type: - improvement >= 1% → info row "ACMR x.xxx → y.yyy (Z% improvement available — run `qtmesh vertex-cache -o ` …)" - improvement < 1% → ok row "ACMR x.xxx — already optimal" 1% is the cutoff so rounding noise doesn't surface a call-to- action when nothing meaningful would change. Manual smoke: `qtmesh vertex-cache ninja.mesh` (analyze-only) now prints ACMR 0.932 → 0.841 (9.7% improvement available) with "Submeshes rewritten: 0" confirming the buffer wasn't touched. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/MeshValidator.cpp | 24 +++++++++++++++++++----- src/VertexCacheOptimizer.cpp | 16 ++++++++++------ 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/src/MeshValidator.cpp b/src/MeshValidator.cpp index 6e0958c5f..bf8794e20 100644 --- a/src/MeshValidator.cpp +++ b/src/MeshValidator.cpp @@ -379,12 +379,26 @@ void MeshValidator::doValidate() cacheReport.weightedAcmrBefore /= cacheReport.totalTriangles; cacheReport.weightedAcmrAfter /= cacheReport.totalTriangles; + const double improvementPct = cacheReport.improvement(); + const bool meaningfulGain = improvementPct >= 1.0; + // Round to 1 dp to avoid surfacing 0.4% as a "fix me" call to action. + QVariantMap issue; - issue["type"] = "info"; - issue["description"] = QString("Vertex cache: ACMR %1 (lower is better; " - "run `qtmesh vertex-cache --rewrite` or the MCP " - "optimize_vertex_cache tool to improve)") - .arg(QString::number(cacheReport.weightedAcmrBefore, 'f', 3)); + if (meaningfulGain) { + issue["type"] = "info"; + issue["description"] = + QString("Vertex cache: ACMR %1 → %2 (%3% improvement available — " + "run `qtmesh vertex-cache -o ` or the MCP " + "optimize_vertex_cache tool with rewrite=true)") + .arg(QString::number(cacheReport.weightedAcmrBefore, 'f', 3), + QString::number(cacheReport.weightedAcmrAfter, 'f', 3), + QString::number(improvementPct, 'f', 1)); + } else { + issue["type"] = "ok"; + issue["description"] = QString("Vertex cache: ACMR %1 — already optimal " + "(no meaningful gain from reordering)") + .arg(QString::number(cacheReport.weightedAcmrBefore, 'f', 3)); + } issue["count"] = 0; issue["fixable"] = false; m_issues.append(issue); diff --git a/src/VertexCacheOptimizer.cpp b/src/VertexCacheOptimizer.cpp index 30faf112d..03fc69980 100644 --- a/src/VertexCacheOptimizer.cpp +++ b/src/VertexCacheOptimizer.cpp @@ -250,12 +250,18 @@ VertexCacheReport VertexCacheOptimizer::analyzeEntity(Ogre::Entity* entity, bool sr.triangleCount = static_cast(indices.size() / 3); sr.acmrBefore = computeAcmr(indices); - if (rewrite && vertexCount > 0) { - std::vector reordered = indices; // forsyth() mutates in place + // Always run Forsyth on a local copy so we know what the optimized + // ACMR would be. This lets the validator / analyze-only mode report + // the projected improvement without mutating the index buffer. + // `reordered` stays false unless we actually write back below. + if (vertexCount > 0) { + std::vector reordered = indices; if (forsyth(reordered, vertexCount)) { sr.acmrAfter = computeAcmr(reordered); - // Only write back if it actually improved — never regress. - if (sr.acmrAfter < sr.acmrBefore) { + + // Only write back when the caller asked AND the reorder + // improves ACMR — never regress. + if (rewrite && sr.acmrAfter < sr.acmrBefore) { void* dst = id->indexBuffer->lock(Ogre::HardwareBuffer::HBL_NORMAL); if (use16) { auto* out = static_cast(dst); @@ -269,8 +275,6 @@ VertexCacheReport VertexCacheOptimizer::analyzeEntity(Ogre::Entity* entity, bool id->indexBuffer->unlock(); sr.reordered = true; ++report.totalReordered; - } else { - sr.acmrAfter = sr.acmrBefore; } } else { sr.acmrAfter = sr.acmrBefore; From f14f2d75f766ea2a8448866ddd4a8d5a2bb97a52 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 12 May 2026 15:54:18 -0400 Subject: [PATCH 3/9] feat(validate): Optimize Vertex Cache button in the Inspector (#498) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User feedback: surfacing the projected ACMR improvement in the validator row is great, but the suggested fix still needs a terminal — "can we allow it to fix from the UI?". Adds an "Optimize Vertex Cache" button below the existing "Fix All" row in the Inspector's validation section. Distinct from Fix All on purpose: - Fix All re-imports through Assimp with cleanup flags (mutates geometry / topology). Shown when the row carries `fixable:true`. - Optimize Vertex Cache only rewrites index-buffer ordering via Forsyth — never touches positions / UVs / materials. Shown via a new `hasCacheOptimization` Q_PROPERTY when the validator computed a >=1% projected improvement. MeshValidator gains an `optimizeVertexCache()` Q_INVOKABLE that runs VertexCacheOptimizer::analyzeEntity(rewrite=true) on each selected entity, emits a `fixApplied` message with the actual before/after ACMR, then re-runs `validate()` so the checklist flips the row to "already optimal". The button uses the same blue (#5090d0) as the info-row glyph so the visual grouping is unambiguous — info row → blue action. Manual smoke: load ninja.mesh, click Run Validation → the cache row reports 9.7% improvement available; click Optimize Vertex Cache → row flips to "ACMR 0.841 — already optimal" and the fixApplied message confirms 1 submesh reordered. Co-Authored-By: Claude Opus 4.7 (1M context) --- qml/PropertiesPanel.qml | 15 ++++++++++++++ src/MeshValidator.cpp | 46 +++++++++++++++++++++++++++++++++++++++-- src/MeshValidator.h | 7 +++++++ 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index ca43fe051..0deddf91d 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -2568,6 +2568,21 @@ Rectangle { } } + // Optimize Vertex Cache button (Phase 6 slice C). Distinct from + // "Fix All" — this only mutates index ordering, never geometry. + Rectangle { + width: parent.width - 16; height: 28; radius: 3 + visible: MeshValidator.hasCacheOptimization + color: cacheMouse.pressed ? Qt.darker("#5090d0", 1.2) + : cacheMouse.containsMouse ? Qt.lighter("#5090d0", 1.2) + : "#5090d0" + Text { anchors.centerIn: parent; text: "Optimize Vertex Cache"; color: "white"; font.pixelSize: 11 } + MouseArea { + id: cacheMouse; anchors.fill: parent; hoverEnabled: true + onClicked: MeshValidator.optimizeVertexCache() + } + } + // Fix feedback Text { id: fixFeedback diff --git a/src/MeshValidator.cpp b/src/MeshValidator.cpp index bf8794e20..636d06b5b 100644 --- a/src/MeshValidator.cpp +++ b/src/MeshValidator.cpp @@ -150,6 +150,7 @@ void MeshValidator::doValidate() { m_issues.clear(); m_validated = false; + m_cacheOptimizationAvailable = false; const QList targets = validationTargetEntities(); if (targets.isEmpty()) { @@ -385,11 +386,11 @@ void MeshValidator::doValidate() QVariantMap issue; if (meaningfulGain) { + m_cacheOptimizationAvailable = true; issue["type"] = "info"; issue["description"] = QString("Vertex cache: ACMR %1 → %2 (%3% improvement available — " - "run `qtmesh vertex-cache -o ` or the MCP " - "optimize_vertex_cache tool with rewrite=true)") + "click \"Optimize Vertex Cache\" below)") .arg(QString::number(cacheReport.weightedAcmrBefore, 'f', 3), QString::number(cacheReport.weightedAcmrAfter, 'f', 3), QString::number(improvementPct, 'f', 1)); @@ -475,3 +476,44 @@ void MeshValidator::fixAll() // Re-validate the newly imported entities validate(); } + +void MeshValidator::optimizeVertexCache() +{ + const QList targets = validationTargetEntities(); + if (targets.isEmpty()) { + emit error("No mesh selected."); + return; + } + + SentryReporter::addBreadcrumb("ui.action", "Optimize vertex cache (Forsyth, in place)"); + + VertexCacheReport aggregate; + for (Ogre::Entity* entity : targets) { + const VertexCacheReport partial = + VertexCacheOptimizer::analyzeEntity(entity, /*rewrite=*/true); + for (const SubMeshCacheReport& sr : partial.submeshes) { + aggregate.submeshes.append(sr); + aggregate.totalTriangles += sr.triangleCount; + aggregate.weightedAcmrBefore += sr.acmrBefore * sr.triangleCount; + aggregate.weightedAcmrAfter += sr.acmrAfter * sr.triangleCount; + if (sr.reordered) ++aggregate.totalReordered; + } + } + if (aggregate.totalTriangles > 0) { + aggregate.weightedAcmrBefore /= aggregate.totalTriangles; + aggregate.weightedAcmrAfter /= aggregate.totalTriangles; + } + + if (aggregate.totalReordered == 0) { + emit fixApplied("Vertex cache was already optimal — no submeshes were reordered."); + } else { + emit fixApplied(QString("Reordered %1 submesh(es). ACMR %2 → %3 (%4% improvement).") + .arg(aggregate.totalReordered) + .arg(QString::number(aggregate.weightedAcmrBefore, 'f', 3), + QString::number(aggregate.weightedAcmrAfter, 'f', 3), + QString::number(aggregate.improvement(), 'f', 1))); + } + + // Refresh the checklist — the row should flip to "already optimal". + validate(); +} diff --git a/src/MeshValidator.h b/src/MeshValidator.h index 5434e3f5b..e01468a87 100644 --- a/src/MeshValidator.h +++ b/src/MeshValidator.h @@ -15,6 +15,7 @@ class MeshValidator : public QObject, public Ogre::FrameListener Q_PROPERTY(bool hasSelection READ hasSelection NOTIFY selectionChanged) Q_PROPERTY(QVariantList issues READ issues NOTIFY issuesChanged) Q_PROPERTY(bool hasFixableIssues READ hasFixableIssues NOTIFY issuesChanged) + Q_PROPERTY(bool hasCacheOptimization READ hasCacheOptimization NOTIFY issuesChanged) Q_PROPERTY(bool validated READ validated NOTIFY issuesChanged) Q_PROPERTY(bool validating READ validating NOTIFY validatingChanged) @@ -26,6 +27,7 @@ class MeshValidator : public QObject, public Ogre::FrameListener bool hasSelection() const; QVariantList issues() const { return m_issues; } bool hasFixableIssues() const; + bool hasCacheOptimization() const { return m_cacheOptimizationAvailable; } bool validated() const { return m_validated; } bool validating() const { return m_pendingValidate; } @@ -33,6 +35,10 @@ class MeshValidator : public QObject, public Ogre::FrameListener // Re-imports the mesh with Assimp cleanup flags to fix degenerate/invalid geometry. // Creates a new cleaned entity alongside the original — delete the original manually. Q_INVOKABLE void fixAll(); + // Phase 6 slice C: in-place vertex-cache reorder via Forsyth's algorithm + // on every selected entity. Mutates Ogre's index buffers — does NOT + // touch the source file. Re-runs validate() to refresh the report. + Q_INVOKABLE void optimizeVertexCache(); // Run validation synchronously (GL context must be current — safe from MCP/CLI context // and from inside the Ogre render loop; use validate() from QML to defer automatically). @@ -58,6 +64,7 @@ class MeshValidator : public QObject, public Ogre::FrameListener QVariantList m_issues; bool m_validated = false; bool m_pendingValidate = false; + bool m_cacheOptimizationAvailable = false; Ogre::Root* m_registeredRoot = nullptr; // which Root we are listening on }; From cd95b0d603043588535275de35038aa3c4b1dadc Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 12 May 2026 15:57:36 -0400 Subject: [PATCH 4/9] fix(validate): clear Optimize button + fix feedback on selection change (#498) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User feedback: when the selection changes, the issue list already clears but the "Optimize Vertex Cache" button stays visible and the green "Reordered N submesh(es)…" feedback line lingers. Two fixes: - MeshValidator::selectionChanged handler now also resets m_cacheOptimizationAvailable so the new hasCacheOptimization Q_PROPERTY notifies false alongside hasFixableIssues — the button hides automatically. - fixFeedback Text in the validation section now listens for onIssuesChanged and clears itself whenever MeshValidator.validated is false. That covers selection-change, no-selection, and any future code path that resets the report. fixApplied / error messages still survive normal validate() runs because validated stays true through those. Co-Authored-By: Claude Opus 4.7 (1M context) --- qml/PropertiesPanel.qml | 7 +++++++ src/MeshValidator.cpp | 1 + 2 files changed, 8 insertions(+) diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 0deddf91d..cd2809403 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -2599,6 +2599,13 @@ Rectangle { fixFeedback.color = "#c06060" fixFeedback.text = msg } + // Clear the fix feedback whenever the validation result is + // invalidated — selection-change, or any other reason the + // validator resets `validated` to false. + function onIssuesChanged() { + if (!MeshValidator.validated) + fixFeedback.text = "" + } } } } diff --git a/src/MeshValidator.cpp b/src/MeshValidator.cpp index 636d06b5b..8b2dcf7d1 100644 --- a/src/MeshValidator.cpp +++ b/src/MeshValidator.cpp @@ -56,6 +56,7 @@ MeshValidator::MeshValidator() : QObject(nullptr) // Clear stale results when selection changes; cancel any pending validation. m_issues.clear(); m_validated = false; + m_cacheOptimizationAvailable = false; if (m_pendingValidate) { m_pendingValidate = false; emit validatingChanged(); From 2e45225b3a784af0f71a7807ae5079d3dc77a1a2 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 12 May 2026 16:01:56 -0400 Subject: [PATCH 5/9] fix(validate): context panel "Findings" + "Suggestions" + "Fixable" now match the row state (#498) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User feedback: after slice C the vertex-cache row carries a one-click fix but the bottom context panel still shows "Findings: 0 / Fixable: No". Considered promoting the row from info → warning to make it count. Rejected: ACMR is a perf metric, not a correctness issue; promoting just this row would make the validator inconsistent with the sibling Draws / GPU info rows. Instead, two surgical fixes: 1. MeshValidator: mark the cache row `fixable: true` when the projected improvement is meaningful (>=1%). The dedicated "Optimize Vertex Cache" button reads `hasCacheOptimization` to appear, but the row's `fixable` flag is what the context panel reads to count "Fixable: Yes". To keep the red "Fix All (re-import with cleanup)" button from accidentally appearing on cache-only fixable cases (it does the Assimp re-import, not the vertex-cache reorder), `hasFixableIssues` now restricts itself to error / warning rows. Info-tier fixables have their own buttons (hasCacheOptimization for now; future slices add more). 2. BottomContextPanel: added a "Suggestions" column alongside "Findings". `Findings` still counts errors+warnings (the must-fix tier); `Suggestions` counts info rows that carry a one-click fix. Both update reactively on validate() / selection change. Visual result: validate a non-optimized mesh, the panel now reads "Findings: 0 Suggestions: 1 Fixable: Yes Status: Ready", and the "Optimize Vertex Cache" button is right there in the panel. Co-Authored-By: Claude Opus 4.7 (1M context) --- qml/BottomContextPanel.qml | 20 ++++++++++++++++++++ src/MeshValidator.cpp | 13 +++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/qml/BottomContextPanel.qml b/qml/BottomContextPanel.qml index 3e3648349..47f318dba 100644 --- a/qml/BottomContextPanel.qml +++ b/qml/BottomContextPanel.qml @@ -16,6 +16,8 @@ Rectangle { property var materialEditorAction: null readonly property string currentSummaryObjectName: summaryLoader.item ? summaryLoader.item.objectName : "" + // Count error+warning rows (the "must fix" tier). Info rows are + // perf observations and counted separately via suggestionSummary(). function issueSummary() { if (!MeshValidator.validated) return "Not run" @@ -29,6 +31,23 @@ Rectangle { return String(n) } + // Phase 6: count info rows that carry a one-click fix (e.g. vertex + // cache reorder). These aren't errors but the user has an in-UI + // action available, so surface them as "Suggestions" in the panel. + function suggestionSummary() { + if (!MeshValidator.validated) + return "Not run" + var n = 0 + for (var i = 0; i < MeshValidator.issues.length; ++i) { + var item = MeshValidator.issues[i] + var typ = item.type !== undefined ? item.type : item["type"] + var fixable = item.fixable === true + if (typ === "info" && fixable) + ++n + } + return String(n) + } + function fileSummary() { return String(AssetBrowserController.files.length) } @@ -196,6 +215,7 @@ Rectangle { SummaryText { label: "Selection"; value: MeshValidator.hasSelection ? PropertiesPanelController.selectionName : "None" } SummaryText { label: "Findings"; value: root.issueSummary() } + SummaryText { label: "Suggestions"; value: root.suggestionSummary() } SummaryText { label: "Fixable"; value: MeshValidator.hasFixableIssues ? "Yes" : "No" } SummaryText { label: "Status"; value: MeshValidator.validating ? "Running" : (MeshValidator.validated ? "Ready" : "Idle") } Item { Layout.fillWidth: true } diff --git a/src/MeshValidator.cpp b/src/MeshValidator.cpp index 8b2dcf7d1..ee5234443 100644 --- a/src/MeshValidator.cpp +++ b/src/MeshValidator.cpp @@ -79,8 +79,14 @@ bool MeshValidator::hasSelection() const bool MeshValidator::hasFixableIssues() const { + // Only error/warning rows trigger the red "Fix All (re-import with + // cleanup)" button — info-tier fixable rows (e.g. vertex cache) have + // their own dedicated buttons and reading list (hasCacheOptimization). for (const QVariant& v : m_issues) { - if (v.toMap().value("fixable").toBool()) + const QVariantMap m = v.toMap(); + if (!m.value("fixable").toBool()) continue; + const QString type = m.value("type").toString(); + if (type == QLatin1String("error") || type == QLatin1String("warning")) return true; } return false; @@ -395,14 +401,17 @@ void MeshValidator::doValidate() .arg(QString::number(cacheReport.weightedAcmrBefore, 'f', 3), QString::number(cacheReport.weightedAcmrAfter, 'f', 3), QString::number(improvementPct, 'f', 1)); + // The row is actionable from the UI — context panel reads this to + // surface "Fixable: Yes" + the new "Suggestions" tier counter. + issue["fixable"] = true; } else { issue["type"] = "ok"; issue["description"] = QString("Vertex cache: ACMR %1 — already optimal " "(no meaningful gain from reordering)") .arg(QString::number(cacheReport.weightedAcmrBefore, 'f', 3)); + issue["fixable"] = false; } issue["count"] = 0; - issue["fixable"] = false; m_issues.append(issue); } From a0c8a06d8c194317c4a64cc3210292ec83ee3ad8 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 12 May 2026 16:15:49 -0400 Subject: [PATCH 6/9] feat(scan): max_acmr rule + Fixable=Yes for one-click cache fix (#498) Two pieces of user feedback on slice C: 1. Context panel "Fixable" now reads Yes for the vertex-cache suggestion. The QML reads MeshValidator.hasFixableIssues OR hasCacheOptimization, mirroring how Suggestions counts info-tier fixable rows separately from Findings. 2. qtmesh scan now flags poor vertex-cache locality. - ScanConfig.maxAcmr (double, 0 = disabled). Loaded from yml/json, scope overrides, and (via the qtmesh-cloud PR landing alongside this) the project's `max_acmr` rule. - AssetInfo.weightedAcmr computed via VertexCacheOptimizer:: computeAcmr on Assimp's flattened per-face triangle indices, weighted by triangle count. Emitted in the per-asset JSON. - evaluateRules adds a `max_acmr` warning when the asset's weighted ACMR exceeds the configured ceiling. - CLI flag --max-acmr . Design note (documented inline): the Assimp index order is NOT the same as Ogre's MeshSerializer order, so the scan's ACMR runs higher than the editor's "Run Validation" ACMR on the same asset (e.g. ninja.mesh scans at 3.0 vs 0.93 in the editor). The scan still catches the meshes that need a reorder; an Ogre-backed scan backend that produces matching numbers is the deliberately-deferred slice C2 (CLAUDE.md says ScanEngine is "lightweight metadata extraction" on purpose, and switching wholesale costs ~500ms+ Ogre init plus O(n) entity cleanup per file). Manual smoke: `qtmesh scan media/models --max-acmr 0.8` warns on the four high-ACMR fbx/mesh files and is silent on robot.mesh (below threshold). JSON output carries `weightedAcmr` on every mesh asset. Co-Authored-By: Claude Opus 4.7 (1M context) --- qml/BottomContextPanel.qml | 4 +++- src/CLIPipeline.cpp | 9 ++++++++ src/ScanConfig.cpp | 2 ++ src/ScanConfig.h | 4 ++++ src/ScanEngine.cpp | 47 +++++++++++++++++++++++++++++++++++++- src/ScanEngine.h | 5 ++++ 6 files changed, 69 insertions(+), 2 deletions(-) diff --git a/qml/BottomContextPanel.qml b/qml/BottomContextPanel.qml index 47f318dba..94b2963c2 100644 --- a/qml/BottomContextPanel.qml +++ b/qml/BottomContextPanel.qml @@ -216,7 +216,9 @@ Rectangle { SummaryText { label: "Selection"; value: MeshValidator.hasSelection ? PropertiesPanelController.selectionName : "None" } SummaryText { label: "Findings"; value: root.issueSummary() } SummaryText { label: "Suggestions"; value: root.suggestionSummary() } - SummaryText { label: "Fixable"; value: MeshValidator.hasFixableIssues ? "Yes" : "No" } + SummaryText { label: "Fixable" + value: (MeshValidator.hasFixableIssues + || MeshValidator.hasCacheOptimization) ? "Yes" : "No" } SummaryText { label: "Status"; value: MeshValidator.validating ? "Running" : (MeshValidator.validated ? "Ready" : "Idle") } Item { Layout.fillWidth: true } } diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index 14ea692df..6aa621db6 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -570,6 +570,7 @@ void CLIPipeline::printUsage() " --min-materials Override min_material_count (0 = no limit)\n" " --max-vertices Override max_vertex_count (0 = no limit)\n" " --min-vertices Override min_vertex_count (0 = no limit)\n" + " --max-acmr Override max_acmr (0 = no limit, e.g. 1.5)\n" " --require-skeleton / --no-require-skeleton\n" " Override require_skeleton\n" " --require-animations / --no-require-animations\n" @@ -2766,6 +2767,7 @@ int CLIPipeline::cmdScan(int argc, char* argv[]) int maxVerticesOverride = -1; int minVerticesOverride = -1; + double maxAcmrOverride = -1.0; int maxMeshesOverride = -1; int minMeshesOverride = -1; int maxMaterialsOverride = -1; @@ -2911,6 +2913,12 @@ int CLIPipeline::cmdScan(int argc, char* argv[]) if (!parseNonNegativeInt("--min-vertices", value, minVerticesOverride)) return 2; continue; } + parseResult = parseValueArg(arg, "--max-acmr", i, value); + if (parseResult == ParseValueResult::Error) return 2; + if (parseResult == ParseValueResult::Matched) { + if (!parseNonNegativeDouble("--max-acmr", value, maxAcmrOverride)) return 2; + continue; + } parseResult = parseValueArg(arg, "--max-meshes", i, value); if (parseResult == ParseValueResult::Error) return 2; if (parseResult == ParseValueResult::Matched) { @@ -3069,6 +3077,7 @@ int CLIPipeline::cmdScan(int argc, char* argv[]) if (minMaterialsOverride >= 0) config.minMaterialCount = minMaterialsOverride; if (maxVerticesOverride >= 0) config.maxVertexCount = maxVerticesOverride; if (minVerticesOverride >= 0) config.minVertexCount = minVerticesOverride; + if (maxAcmrOverride >= 0.0) config.maxAcmr = maxAcmrOverride; if (maxAnimKeyframesOverride >= 0) config.maxAnimKeyframes = maxAnimKeyframesOverride; if (minAnimKeyframesOverride >= 0) config.minAnimKeyframes = minAnimKeyframesOverride; if (maxAnimDurationOverride >= 0.0) config.maxAnimDuration = maxAnimDurationOverride; diff --git a/src/ScanConfig.cpp b/src/ScanConfig.cpp index 36328bb66..78d4ee16f 100644 --- a/src/ScanConfig.cpp +++ b/src/ScanConfig.cpp @@ -256,6 +256,7 @@ void ScanConfig::applyRuleOverrides(const QVariantMap& r) if (r.contains("min_material_count")) minMaterialCount = r["min_material_count"].toInt(); if (r.contains("max_vertex_count")) maxVertexCount = r["max_vertex_count"].toInt(); if (r.contains("min_vertex_count")) minVertexCount = r["min_vertex_count"].toInt(); + if (r.contains("max_acmr")) maxAcmr = r["max_acmr"].toDouble(); if (r.contains("require_skeleton")) requireSkeleton = r["require_skeleton"].toBool(); if (r.contains("require_animations")) requireAnimations = r["require_animations"].toBool(); if (r.contains("allow_embedded_textures")) allowEmbeddedTextures = r["allow_embedded_textures"].toBool(); @@ -434,6 +435,7 @@ ScanConfig ScanConfig::fromVariantMap(const QVariantMap& root) config.minMaterialCount = rules.value("min_material_count", config.minMaterialCount).toInt(); config.maxVertexCount = rules.value("max_vertex_count", config.maxVertexCount).toInt(); config.minVertexCount = rules.value("min_vertex_count", config.minVertexCount).toInt(); + config.maxAcmr = rules.value("max_acmr", config.maxAcmr).toDouble(); config.requireSkeleton = rules.value("require_skeleton", config.requireSkeleton).toBool(); config.requireAnimations = rules.value("require_animations", config.requireAnimations).toBool(); config.allowEmbeddedTextures = rules.value("allow_embedded_textures", config.allowEmbeddedTextures).toBool(); diff --git a/src/ScanConfig.h b/src/ScanConfig.h index f16e89639..159266dfc 100644 --- a/src/ScanConfig.h +++ b/src/ScanConfig.h @@ -37,6 +37,10 @@ struct ScanConfig { int minMaterialCount = 0; int maxVertexCount = 0; int minVertexCount = 0; + // Phase 6 slice C: ACMR (Average Cache Miss Ratio) ceiling for vertex + // cache friendliness. 0 = disabled. A typical post-T&L cache (32 entries) + // hits ~0.6 on a well-ordered mesh and ~2.0-3.0 on a shuffled one. + double maxAcmr = 0.0; bool requireSkeleton = false; // true = error if no skeleton bool requireAnimations = false; // true = error if no animations bool allowEmbeddedTextures = true; diff --git a/src/ScanEngine.cpp b/src/ScanEngine.cpp index 3701bc16a..9308f669f 100644 --- a/src/ScanEngine.cpp +++ b/src/ScanEngine.cpp @@ -27,6 +27,7 @@ #include "MeshImporterExporter.h" #include "AnimationMerger.h" #include "FBX/FBXExporter.h" +#include "VertexCacheOptimizer.h" #include #include @@ -712,8 +713,10 @@ AssetInfo ScanEngine::inspectAsset(const QString& filePath, const QString& scanR info.materialCount = scene->mNumMaterials; info.animationCount = scene->mNumAnimations; - // Vertex & face counts + skeleton detection + // Vertex & face counts + skeleton detection + weighted ACMR std::set uniqueBones; + double acmrTriWeightedSum = 0.0; + unsigned int acmrTotalTris = 0; for (unsigned i = 0; i < scene->mNumMeshes; ++i) { const aiMesh* mesh = scene->mMeshes[i]; if (!mesh) @@ -725,7 +728,29 @@ AssetInfo ScanEngine::inspectAsset(const QString& filePath, const QString& scanR continue; uniqueBones.insert(mesh->mBones[b]->mName.C_Str()); } + + // Phase 6 slice C: ACMR for cache friendliness. Flatten Assimp's + // per-face indices into a flat uint32 vector and run the same + // pure-data primitive the editor / CLI use. Skip non-triangle + // primitives (point clouds, lines, strips). + std::vector idxFlat; + idxFlat.reserve(static_cast(mesh->mNumFaces) * 3); + for (unsigned f = 0; f < mesh->mNumFaces; ++f) { + const aiFace& face = mesh->mFaces[f]; + if (face.mNumIndices != 3) continue; + idxFlat.push_back(face.mIndices[0]); + idxFlat.push_back(face.mIndices[1]); + idxFlat.push_back(face.mIndices[2]); + } + if (!idxFlat.empty()) { + const double acmr = VertexCacheOptimizer::computeAcmr(idxFlat); + const unsigned int tris = static_cast(idxFlat.size() / 3); + acmrTriWeightedSum += acmr * tris; + acmrTotalTris += tris; + } } + if (acmrTotalTris > 0) + info.weightedAcmr = acmrTriWeightedSum / acmrTotalTris; info.boneCount = static_cast(uniqueBones.size()); info.hasSkeleton = !uniqueBones.empty(); for (const auto& boneName : uniqueBones) @@ -957,6 +982,23 @@ QList ScanEngine::evaluateRules(const AssetInfo& asset, const ScanConfi QString("%1 vertices is below minimum of %2") .arg(asset.vertexCount).arg(config.minVertexCount)}); + // ---- max_acmr ---- (Phase 6 slice C) + // + // ACMR is measured on Assimp's flattened triangle list, which does not + // perfectly match the order Ogre's MeshSerializer produces — Assimp's + // numbers tend to run higher than the editor's report. The rule still + // catches the meshes that need a reorder; the exact threshold needs + // calibration to the Assimp pipeline (1.5 is a reasonable starting + // point for "this asset should be reordered"). The Ogre-backed scan + // backend (slice C2) will produce matching numbers when available. + if (config.maxAcmr > 0.0 && asset.weightedAcmr > config.maxAcmr) { + findings.append({asset.relativePath, "max_acmr", Severity::Warning, + QString("ACMR %1 exceeds limit of %2 — reorder index buffer for " + "GPU vertex cache (qtmesh vertex-cache -o )") + .arg(QString::number(asset.weightedAcmr, 'f', 3)) + .arg(QString::number(config.maxAcmr, 'f', 3))}); + } + // ---- require_skeleton ---- if (config.requireSkeleton && !asset.hasSkeleton) { findings.append({asset.relativePath, "require_skeleton", Severity::Error, @@ -1766,6 +1808,8 @@ QJsonObject ScanEngine::scanReportToJsonObject(const ScanResult& result) ao["textureRefCount"] = static_cast(asset.textureRefCount); if (asset.animationRedundantKeyframeRatio > 0.0) ao["animationRedundantKeyframeRatio"] = asset.animationRedundantKeyframeRatio; + if (asset.weightedAcmr > 0.0) + ao["weightedAcmr"] = asset.weightedAcmr; if (!asset.animationNames.isEmpty()) { QJsonArray anims; @@ -1873,6 +1917,7 @@ QString ScanEngine::formatSarif(const ScanResult& result) ruleDescriptions["max_mesh_count"] = "Asset exceeds maximum mesh count"; ruleDescriptions["max_material_count"] = "Asset exceeds maximum material count"; ruleDescriptions["max_vertex_count"] = "Asset exceeds maximum vertex count"; + ruleDescriptions["max_acmr"] = "Asset's vertex-cache ACMR exceeds the configured ceiling — reorder for GPU efficiency"; ruleDescriptions["require_skeleton"] = "Asset is missing a required skeleton"; ruleDescriptions["require_animations"] = "Asset is missing required animations"; ruleDescriptions["allow_embedded_textures"] = "Asset contains embedded textures"; diff --git a/src/ScanEngine.h b/src/ScanEngine.h index 9f7698764..c582eb11a 100644 --- a/src/ScanEngine.h +++ b/src/ScanEngine.h @@ -59,6 +59,11 @@ struct AssetInfo { /// Fraction of atomic node keys that are redundant under balanced simplify tolerances (0..1), file-level aggregate. double animationRedundantKeyframeRatio = 0.0; + /// Phase 6 slice C: weighted ACMR across all triangulated meshes (0 = unknown / no indices). + /// Computed via VertexCacheOptimizer::computeAcmr on Assimp's flattened + /// per-face indices, weighted by triangle count. + double weightedAcmr = 0.0; + // Redundant-keyframe analysis (filled when scan rule is active). // Total keyframes summed across all tracks of all animations. int totalKeyframes = 0; From e15c46b8ef5da65c3578c3d6a70fad308e1d0908 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 12 May 2026 16:38:28 -0400 Subject: [PATCH 7/9] refactor(perf): clear remaining SonarCloud findings on slice C (#498) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quality Gate already passed; this clears the 24 issues flagged on the new code by extracting helpers and applying mechanical const / init-statement fixes throughout. Structural refactors (S3776 cognitive complexity): VertexCacheOptimizer.cpp / forsyth() — 59 → ~15 - Extracted buildTriangleAdjacency, cachePushFront, enforceCacheCapacity, triangleScore, and findNextBestTriangle helpers. The main loop is now a six-line state machine: emit → push-to-cache → evict → re-score cached verts → pick next best. - ScoreTable's 32-entry c-array swapped for std::array. - vertexScore's nested ternary on cache-position split into a named cachePositionScore() so each branch reads independently. - Out-of-range index check moved to a single pre-loop pass before any allocation, dropping a branch in the hot CSR-build path. VertexCacheOptimizer.cpp / analyzeEntity() — 52 → ~10 - Extracted readIndexBuffer / writeIndexBuffer (the 16/32-bit index lock/copy/unlock dance) and analyzeSubMesh (per-submesh Forsyth + writeback) into anonymous-namespace helpers. - New public helpers mergeReport() / finalize() collapse the multi-entity aggregation that was duplicated in MCP and CLI callers; both call sites are now three lines. CLIPipeline.cpp / cmdVertexCache() — 33 → ~15 - Extracted parseVertexCacheArgs, exportRewrittenMesh, emitVertexCacheReport into anonymous-namespace helpers (same pattern as cmdMemory in slice A). cmdVertexCache is now a thin orchestrator. Mechanical fixes (S5350 / S6004 / S5827 / S5276): - const Ogre::SceneNode* / const Ogre::Entity* in scene walks. - const Ogre::SubMesh* / IndexData* / VertexData* in the per- submesh helper. - Init-statement on the cache.find() in computeAcmr's inner loop. - ScanEngine's tris cast now uses `const auto` (mirrors slice B's pattern for static_cast assignments). - Removed redundant `static` on the ScoreTable singleton (it's already in an anonymous namespace, so static is redundant). The remaining MCPServer.cpp:2798 S5817 ("should be const") is the same intentional NOSONAR pattern as slice A's get_memory_usage and slice B's analyze_draw_calls — ToolHandler is a non-const member-fn pointer. Manual smoke: qtmesh vertex-cache media/models/ninja.mesh -o out still produces ACMR 0.932 → 0.841 (9.7%) with 1/2 submeshes reordered. UnitTests link clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/CLIPipeline.cpp | 120 ++++++----- src/MCPServer.cpp | 18 +- src/ScanEngine.cpp | 2 +- src/VertexCacheOptimizer.cpp | 392 +++++++++++++++++++++-------------- src/VertexCacheOptimizer.h | 10 + 5 files changed, 322 insertions(+), 220 deletions(-) diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index 6aa621db6..296ec36cf 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -3488,38 +3488,88 @@ int CLIPipeline::cmdAnalyze(int argc, char* argv[]) return 0; } -int CLIPipeline::cmdVertexCache(int argc, char* argv[]) -{ - // Parse: vertex-cache [-o ] [--json] +namespace { + +struct VertexCacheCmdArgs { QString filePath; QString outputPath; bool jsonOutput = false; +}; +// Parse argv for cmdVertexCache. Returns true on success. +bool parseVertexCacheArgs(int argc, char* argv[], VertexCacheCmdArgs& out) +{ int i = 1; while (i < argc) { const QString arg(argv[i]); ++i; if (arg == "vertex-cache" || arg == "--cli") continue; - if (arg == "--json") { jsonOutput = true; continue; } - if (arg == "-o" && i < argc) { outputPath = argv[i++]; continue; } - if (!arg.startsWith("-") && filePath.isEmpty()) filePath = arg; + if (arg == "--json") { out.jsonOutput = true; continue; } + if (arg == "-o" && i < argc) { out.outputPath = argv[i++]; continue; } + if (!arg.startsWith("-") && out.filePath.isEmpty()) out.filePath = arg; } + return !out.filePath.isEmpty(); +} - if (filePath.isEmpty()) { +// Export the (potentially mutated) scene rooted at `entities.first()` to +// `outputPath`. Returns 0 on success, 1 on export failure. +int exportRewrittenMesh(const QList& entities, + const QFileInfo& srcFi, const QString& outputPath) +{ + Ogre::Entity* entity = entities.first(); + auto* node = entity->getParentSceneNode(); + const QFileInfo outFi(outputPath); + const QString fmt = CLIPipeline::formatForExtension(outputPath); + SentryReporter::addBreadcrumb("file.export", + QString("Exporting %1").arg(outFi.absoluteFilePath())); + if (MeshImporterExporter::exporter(node, outFi.absoluteFilePath(), fmt) != 0) { + SentryReporter::captureMessage( + QString("CLI vertex-cache: export failed (.%1 -> .%2)") + .arg(srcFi.suffix(), outFi.suffix()), "error"); + err() << "Error: Export failed." << Qt::endl; + return 1; + } + return 0; +} + +void emitVertexCacheReport(const VertexCacheReport& report, const QFileInfo& fi, + const QString& outputPath, bool jsonOutput) +{ + const bool rewrite = !outputPath.isEmpty(); + if (jsonOutput) { + QJsonObject obj = VertexCacheOptimizer::toJson(report); + obj["file"] = fi.fileName(); + if (rewrite) obj["output"] = QFileInfo(outputPath).fileName(); + cliWrite(QString::fromUtf8(QJsonDocument(obj).toJson(QJsonDocument::Indented))); + } else { + cliWrite(QString("File: %1%2\n") + .arg(fi.fileName(), + rewrite ? QString(" -> %1").arg(QFileInfo(outputPath).fileName()) + : QString())); + cliWrite(VertexCacheOptimizer::toText(report)); + } +} + +} // namespace + +int CLIPipeline::cmdVertexCache(int argc, char* argv[]) +{ + VertexCacheCmdArgs cmdArgs; + if (!parseVertexCacheArgs(argc, argv, cmdArgs)) { err() << "Error: No input file specified." << Qt::endl; err() << "Usage: qtmesh vertex-cache [-o ] [--json]" << Qt::endl; return 2; } - const QFileInfo fi(filePath); + const QFileInfo fi(cmdArgs.filePath); if (!fi.exists()) { - err() << "Error: File not found: " << filePath << Qt::endl; + err() << "Error: File not found: " << cmdArgs.filePath << Qt::endl; return 1; } if (!initOgreHeadless()) return 1; - const bool rewrite = !outputPath.isEmpty(); + const bool rewrite = !cmdArgs.outputPath.isEmpty(); SentryReporter::addBreadcrumb("cli.vertex-cache", QString("Vertex-cache .%1%2").arg(fi.suffix(), rewrite ? " rewrite" : " analyze")); SentryReporter::addBreadcrumb("file.import", @@ -3528,58 +3578,22 @@ int CLIPipeline::cmdVertexCache(int argc, char* argv[]) MeshImporterExporter::importer({fi.absoluteFilePath()}, 0); auto& entities = Manager::getSingleton()->getEntities(); if (entities.isEmpty()) { - err() << "Error: Failed to load file: " << filePath << Qt::endl; + err() << "Error: Failed to load file: " << cmdArgs.filePath << Qt::endl; return 1; } VertexCacheReport aggregate; for (Ogre::Entity* entity : entities) { - const VertexCacheReport partial = - VertexCacheOptimizer::analyzeEntity(entity, rewrite); - // Merge into aggregate (preserve per-submesh rows; recompute the - // weighted ACMR from the running tri/total sums). - for (const SubMeshCacheReport& sr : partial.submeshes) { - aggregate.submeshes.append(sr); - aggregate.totalTriangles += sr.triangleCount; - aggregate.weightedAcmrBefore += sr.acmrBefore * sr.triangleCount; - aggregate.weightedAcmrAfter += sr.acmrAfter * sr.triangleCount; - if (sr.reordered) ++aggregate.totalReordered; - } - } - if (aggregate.totalTriangles > 0) { - aggregate.weightedAcmrBefore /= aggregate.totalTriangles; - aggregate.weightedAcmrAfter /= aggregate.totalTriangles; + VertexCacheOptimizer::mergeReport( + aggregate, VertexCacheOptimizer::analyzeEntity(entity, rewrite)); } + VertexCacheOptimizer::finalize(aggregate); if (rewrite) { - Ogre::Entity* entity = entities.first(); - auto* node = entity->getParentSceneNode(); - const QFileInfo outFi(outputPath); - const QString fmt = formatForExtension(outputPath); - SentryReporter::addBreadcrumb("file.export", - QString("Exporting %1").arg(outFi.absoluteFilePath())); - const int result = MeshImporterExporter::exporter( - node, outFi.absoluteFilePath(), fmt); - if (result != 0) { - SentryReporter::captureMessage( - QString("CLI vertex-cache: export failed (.%1 -> .%2)") - .arg(fi.suffix(), outFi.suffix()), "error"); - err() << "Error: Export failed." << Qt::endl; - return 1; - } + if (const int rc = exportRewrittenMesh(entities, fi, cmdArgs.outputPath); rc != 0) + return rc; } - if (jsonOutput) { - QJsonObject obj = VertexCacheOptimizer::toJson(aggregate); - obj["file"] = fi.fileName(); - if (rewrite) obj["output"] = QFileInfo(outputPath).fileName(); - cliWrite(QString::fromUtf8(QJsonDocument(obj).toJson(QJsonDocument::Indented))); - } else { - cliWrite(QString("File: %1%2\n") - .arg(fi.fileName(), - rewrite ? QString(" -> %1").arg(QFileInfo(outputPath).fileName()) - : QString())); - cliWrite(VertexCacheOptimizer::toText(aggregate)); - } + emitVertexCacheReport(aggregate, fi, cmdArgs.outputPath, cmdArgs.jsonOutput); return 0; } diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp index da11bae5f..33f9ae3f2 100644 --- a/src/MCPServer.cpp +++ b/src/MCPServer.cpp @@ -2806,27 +2806,17 @@ QJsonObject MCPServer::toolOptimizeVertexCache(const QJsonObject &args) const bool rewrite = args.value("rewrite").toBool(false); VertexCacheReport aggregate; - for (Ogre::SceneNode* node : Manager::getSingleton()->getSceneNodes()) { + for (const Ogre::SceneNode* node : Manager::getSingleton()->getSceneNodes()) { if (!node) continue; for (unsigned i = 0; i < node->numAttachedObjects(); ++i) { Ogre::MovableObject* obj = node->getAttachedObject(i); if (!obj || obj->getMovableType() != "Entity") continue; auto* entity = static_cast(obj); - const VertexCacheReport partial = - VertexCacheOptimizer::analyzeEntity(entity, rewrite); - for (const SubMeshCacheReport& sr : partial.submeshes) { - aggregate.submeshes.append(sr); - aggregate.totalTriangles += sr.triangleCount; - aggregate.weightedAcmrBefore += sr.acmrBefore * sr.triangleCount; - aggregate.weightedAcmrAfter += sr.acmrAfter * sr.triangleCount; - if (sr.reordered) ++aggregate.totalReordered; - } + VertexCacheOptimizer::mergeReport( + aggregate, VertexCacheOptimizer::analyzeEntity(entity, rewrite)); } } - if (aggregate.totalTriangles > 0) { - aggregate.weightedAcmrBefore /= aggregate.totalTriangles; - aggregate.weightedAcmrAfter /= aggregate.totalTriangles; - } + VertexCacheOptimizer::finalize(aggregate); QJsonObject result = makeSuccessResult(VertexCacheOptimizer::toText(aggregate)); result["vertexCache"] = VertexCacheOptimizer::toJson(aggregate); diff --git a/src/ScanEngine.cpp b/src/ScanEngine.cpp index 9308f669f..b181d97f2 100644 --- a/src/ScanEngine.cpp +++ b/src/ScanEngine.cpp @@ -744,7 +744,7 @@ AssetInfo ScanEngine::inspectAsset(const QString& filePath, const QString& scanR } if (!idxFlat.empty()) { const double acmr = VertexCacheOptimizer::computeAcmr(idxFlat); - const unsigned int tris = static_cast(idxFlat.size() / 3); + const auto tris = static_cast(idxFlat.size() / 3); acmrTriWeightedSum += acmr * tris; acmrTotalTris += tris; } diff --git a/src/VertexCacheOptimizer.cpp b/src/VertexCacheOptimizer.cpp index 03fc69980..386b51795 100644 --- a/src/VertexCacheOptimizer.cpp +++ b/src/VertexCacheOptimizer.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -35,29 +36,124 @@ constexpr float kValenceBoostPow = 0.5f; // Pre-compute the per-cache-position score so the inner loop is a table lookup. struct ScoreTable { - float cache[kMaxCachePos + 1] = {}; + std::array cache{}; ScoreTable() { // Slots 0..2 are the most-recently-emitted three vertices of the // current triangle — they share the same flat score (Forsyth). cache[0] = cache[1] = cache[2] = kLastTriScore; for (int i = 3; i <= kMaxCachePos; ++i) { - const float p = (kMaxCachePos - i) / static_cast(kMaxCachePos - 3); + const float p = static_cast(kMaxCachePos - i) + / static_cast(kMaxCachePos - 3); cache[i] = std::pow(p, kCacheDecayPow); } } }; -static const ScoreTable g_scoreTable; +const ScoreTable g_scoreTable; + +float cachePositionScore(int cachePosition) +{ + if (cachePosition < 0) return 0.0f; + if (cachePosition >= kMaxCachePos) return 0.0f; + return g_scoreTable.cache[cachePosition]; +} float vertexScore(int cachePosition, int remainingValence) { if (remainingValence <= 0) return -1.0f; // already fully used - float s = (cachePosition < 0) ? 0.0f - : (cachePosition < kMaxCachePos ? g_scoreTable.cache[cachePosition] : 0.0f); + float s = cachePositionScore(cachePosition); s += kValenceBoostScl * std::pow(static_cast(remainingValence), -kValenceBoostPow); return s; } +// Build a CSR-style per-vertex active-triangle list. triOffset[v] is the start +// of v's entries in triList; triOffset[v+1] - triOffset[v] is v's valence. +void buildTriangleAdjacency(const std::vector& indices, + uint32_t vertexCount, + std::vector& valence, + std::vector& triOffset, + std::vector& triList) +{ + const size_t triangleCount = indices.size() / 3; + valence.assign(vertexCount, 0); + for (uint32_t v : indices) ++valence[v]; + + triOffset.assign(vertexCount + 1, 0); + for (uint32_t v = 0; v < vertexCount; ++v) + triOffset[v + 1] = triOffset[v] + valence[v]; + + triList.assign(triOffset.back(), 0); + std::vector cursor(vertexCount, 0); + for (size_t t = 0; t < triangleCount; ++t) { + for (size_t j = 0; j < 3; ++j) { + const uint32_t v = indices[t * 3 + j]; + triList[triOffset[v] + cursor[v]++] = static_cast(t); + } + } +} + +// Move v to the front of the LRU cache (moving / inserting as needed). +void cachePushFront(std::vector& cache, int32_t v) +{ + if (const auto it = std::find(cache.begin(), cache.end(), v); it != cache.end()) + cache.erase(it); + cache.insert(cache.begin(), v); +} + +// Evict any cache entries past `cacheSize`, mark them not-in-cache, and +// refresh cachePos[] for whatever remains. +void enforceCacheCapacity(std::vector& cache, int cacheSize, + std::vector& cachePos) +{ + if (static_cast(cache.size()) > cacheSize) { + for (size_t i = cacheSize; i < cache.size(); ++i) + cachePos[cache[i]] = -1; + cache.resize(cacheSize); + } + for (size_t i = 0; i < cache.size(); ++i) + cachePos[cache[i]] = static_cast(i); +} + +// Sum the three vertex scores of triangle `t`. +float triangleScore(size_t t, const std::vector& indices, + const std::vector& vScore) +{ + return vScore[indices[t * 3]] + + vScore[indices[t * 3 + 1]] + + vScore[indices[t * 3 + 2]]; +} + +// Pick the highest-scoring pending triangle: first scan the triangles +// adjacent to cached vertices (the fast path Forsyth's algorithm normally +// hits), then fall back to a full scan if every cached vertex's neighbors +// are already emitted (rare — small disjoint islands). +int findNextBestTriangle(const std::vector& cache, + const std::vector& triOffset, + const std::vector& triList, + const std::vector& emitted, + const std::vector& indices, + const std::vector& vScore, + std::vector& tScore) +{ + float bestScore = -1.0f; + int bestTri = -1; + for (const int32_t v : cache) { + for (int k = triOffset[v]; k < triOffset[v + 1]; ++k) { + const int t = triList[k]; + if (emitted[t]) continue; + tScore[t] = triangleScore(t, indices, vScore); + if (tScore[t] > bestScore) { bestScore = tScore[t]; bestTri = t; } + } + } + if (bestTri >= 0) return bestTri; + + for (size_t t = 0; t < emitted.size(); ++t) { + if (emitted[t]) continue; + if (tScore[t] > bestScore) { bestScore = tScore[t]; bestTri = static_cast(t); } + } + return bestTri; +} + } // namespace bool VertexCacheOptimizer::forsyth(std::vector& indices, uint32_t vertexCount, @@ -66,115 +162,65 @@ bool VertexCacheOptimizer::forsyth(std::vector& indices, uint32_t vert if (indices.empty() || indices.size() % 3 != 0 || vertexCount == 0) return false; if (cacheSize > kMaxCachePos) cacheSize = kMaxCachePos; - const size_t triangleCount = indices.size() / 3; - - // Per-vertex remaining-valence count (number of triangles still pending). - std::vector valence(vertexCount, 0); - for (uint32_t v : indices) { + // Bounds check the input before any allocation — out-of-range index is + // a fatal input error. + for (const uint32_t v : indices) { if (v >= vertexCount) return false; - ++valence[v]; } - // Per-vertex active-triangle list, packed CSR-style. - std::vector triOffset(vertexCount + 1, 0); - for (uint32_t v = 0; v < vertexCount; ++v) - triOffset[v + 1] = triOffset[v] + valence[v]; + const size_t triangleCount = indices.size() / 3; - std::vector triList(triOffset.back(), 0); - { - std::vector cursor(vertexCount, 0); - for (size_t t = 0; t < triangleCount; ++t) { - for (size_t j = 0; j < 3; ++j) { - const uint32_t v = indices[t * 3 + j]; - triList[triOffset[v] + cursor[v]++] = static_cast(t); - } - } - } + std::vector valence; + std::vector triOffset; + std::vector triList; + buildTriangleAdjacency(indices, vertexCount, valence, triOffset, triList); - // Per-vertex cache position (-1 = not in cache). + // Per-vertex state. std::vector cachePos(vertexCount, -1); - // Per-triangle "already emitted" flag. - std::vector emitted(triangleCount, 0); - // Per-vertex current score (lazy update — only re-computed when valence - // or cache state changes). std::vector vScore(vertexCount, 0.0f); for (uint32_t v = 0; v < vertexCount; ++v) vScore[v] = vertexScore(-1, valence[v]); - // Per-triangle score (sum of its three vertex scores). + + // Per-triangle state. + std::vector emitted(triangleCount, 0); std::vector tScore(triangleCount, 0.0f); - for (size_t t = 0; t < triangleCount; ++t) { - tScore[t] = vScore[indices[t * 3]] - + vScore[indices[t * 3 + 1]] - + vScore[indices[t * 3 + 2]]; - } + for (size_t t = 0; t < triangleCount; ++t) + tScore[t] = triangleScore(t, indices, vScore); - // LRU cache. + // LRU cache + output buffer. std::vector cache; - cache.reserve(cacheSize + 3); - + cache.reserve(static_cast(cacheSize) + 3); std::vector output; output.reserve(indices.size()); + // Seed the loop with the best-scoring triangle in the initial state. int bestTri = -1; - float bestScore = -1.0f; - // First seed: scan the whole triangle list once. - for (size_t t = 0; t < triangleCount; ++t) { - if (tScore[t] > bestScore) { bestScore = tScore[t]; bestTri = static_cast(t); } + { + float bestScore = -1.0f; + for (size_t t = 0; t < triangleCount; ++t) { + if (tScore[t] > bestScore) { + bestScore = tScore[t]; + bestTri = static_cast(t); + } + } } while (bestTri >= 0) { - // Emit the triangle. emitted[bestTri] = 1; for (size_t j = 0; j < 3; ++j) { const uint32_t v = indices[bestTri * 3 + j]; output.push_back(v); --valence[v]; + cachePushFront(cache, static_cast(v)); } + enforceCacheCapacity(cache, cacheSize, cachePos); - // Move the triangle's three vertices to cache front. - for (size_t j = 0; j < 3; ++j) { - const int32_t v = static_cast(indices[bestTri * 3 + j]); - auto it = std::find(cache.begin(), cache.end(), v); - if (it != cache.end()) cache.erase(it); - cache.insert(cache.begin(), v); - } - // Evict overflow and clear their cachePos. - if (static_cast(cache.size()) > cacheSize) { - for (size_t i = cacheSize; i < cache.size(); ++i) - cachePos[cache[i]] = -1; - cache.resize(cacheSize); - } - // Refresh cache positions. - for (size_t i = 0; i < cache.size(); ++i) - cachePos[cache[i]] = static_cast(i); - - // Re-score every vertex still in the cache. - for (int32_t v : cache) vScore[v] = vertexScore(cachePos[v], valence[v]); - - // Re-score every triangle still pending that references any cache vert. - bestScore = -1.0f; - int nextBest = -1; - for (int32_t v : cache) { - const int begin = triOffset[v]; - const int end = triOffset[v + 1]; - for (int k = begin; k < end; ++k) { - const int t = triList[k]; - if (emitted[t]) continue; - tScore[t] = vScore[indices[t * 3]] - + vScore[indices[t * 3 + 1]] - + vScore[indices[t * 3 + 2]]; - if (tScore[t] > bestScore) { bestScore = tScore[t]; nextBest = t; } - } - } - // Fallback: nothing in cache references a pending triangle — scan all. - // Rare but correct (small disjoint islands). - if (nextBest < 0) { - for (size_t t = 0; t < triangleCount; ++t) { - if (emitted[t]) continue; - if (tScore[t] > bestScore) { bestScore = tScore[t]; nextBest = static_cast(t); } - } - } - bestTri = nextBest; + // Re-score every vertex still in the cache (only their scores change). + for (const int32_t v : cache) + vScore[v] = vertexScore(cachePos[v], valence[v]); + + bestTri = findNextBestTriangle(cache, triOffset, triList, emitted, + indices, vScore, tScore); } if (output.size() != indices.size()) return false; // sanity guard @@ -191,9 +237,10 @@ double VertexCacheOptimizer::computeAcmr(const std::vector& indices, i cache.reserve(cacheSize + 1); size_t misses = 0; - for (uint32_t v : indices) { - auto it = std::find(cache.begin(), cache.end(), static_cast(v)); - if (it == cache.end()) { + for (const uint32_t v : indices) { + if (const auto it = std::find(cache.begin(), cache.end(), + static_cast(v)); + it == cache.end()) { ++misses; cache.insert(cache.begin(), static_cast(v)); if (static_cast(cache.size()) > cacheSize) @@ -209,6 +256,105 @@ double VertexCacheOptimizer::computeAcmr(const std::vector& indices, i // ----- Ogre-backed wrapper --------------------------------------------------- // LCOV_EXCL_START — Ogre-only branch, covered indirectly by manual / CLI tests + +namespace { + +// Read an Ogre IndexData into a uint32 vector. Handles 16/32-bit index types +// transparently so the optimizer sees a single uniform format. +std::vector readIndexBuffer(Ogre::IndexData* id) +{ + std::vector indices(id->indexCount); + const bool use16 = (id->indexBuffer->getType() == Ogre::HardwareIndexBuffer::IT_16BIT); + const void* src = id->indexBuffer->lock(Ogre::HardwareBuffer::HBL_READ_ONLY); + if (use16) { + const auto* in = static_cast(src); + for (size_t i = 0; i < id->indexCount; ++i) indices[i] = in[id->indexStart + i]; + } else { + const auto* in = static_cast(src); + for (size_t i = 0; i < id->indexCount; ++i) indices[i] = in[id->indexStart + i]; + } + id->indexBuffer->unlock(); + return indices; +} + +// Write a uint32 vector back into an Ogre IndexData, narrowing to 16-bit +// when the underlying buffer is 16-bit (Forsyth never introduces new indices, +// so all values fit in the existing buffer's width). +void writeIndexBuffer(Ogre::IndexData* id, const std::vector& indices) +{ + const bool use16 = (id->indexBuffer->getType() == Ogre::HardwareIndexBuffer::IT_16BIT); + void* dst = id->indexBuffer->lock(Ogre::HardwareBuffer::HBL_NORMAL); + if (use16) { + auto* out = static_cast(dst); + for (size_t i = 0; i < indices.size(); ++i) + out[id->indexStart + i] = static_cast(indices[i]); + } else { + auto* out = static_cast(dst); + for (size_t i = 0; i < indices.size(); ++i) + out[id->indexStart + i] = indices[i]; + } + id->indexBuffer->unlock(); +} + +// Analyze a single submesh's index buffer. When `rewrite` is true AND the +// reorder strictly improves ACMR, write the reordered indices back through +// Ogre and set `sr.reordered = true`. +void analyzeSubMesh(const Ogre::MeshPtr& mesh, unsigned si, bool rewrite, + SubMeshCacheReport& sr) +{ + const Ogre::SubMesh* sub = mesh->getSubMesh(si); + if (!sub) return; + Ogre::IndexData* id = sub->indexData; + if (!id || !id->indexBuffer || id->indexCount < 3 || id->indexCount % 3 != 0) return; + + std::vector indices = readIndexBuffer(id); + + const Ogre::VertexData* vd = sub->useSharedVertices ? mesh->sharedVertexData + : sub->vertexData; + const uint32_t vertexCount = vd ? static_cast(vd->vertexCount) : 0; + + sr.submeshIndex = static_cast(si); + sr.triangleCount = static_cast(indices.size() / 3); + sr.acmrBefore = VertexCacheOptimizer::computeAcmr(indices); + sr.acmrAfter = sr.acmrBefore; + + if (vertexCount == 0) return; + + // Always run Forsyth on a local copy so the report shows the projected + // ACMR even in analyze-only mode. `reordered` flips to true only when + // bytes actually changed on the underlying buffer. + std::vector reordered = indices; + if (!VertexCacheOptimizer::forsyth(reordered, vertexCount)) return; + + sr.acmrAfter = VertexCacheOptimizer::computeAcmr(reordered); + if (rewrite && sr.acmrAfter < sr.acmrBefore) { + writeIndexBuffer(id, reordered); + sr.reordered = true; + } +} + +} // namespace + +void VertexCacheOptimizer::mergeReport(VertexCacheReport& aggregate, + const VertexCacheReport& partial) +{ + for (const SubMeshCacheReport& sr : partial.submeshes) { + aggregate.submeshes.append(sr); + aggregate.totalTriangles += sr.triangleCount; + aggregate.weightedAcmrBefore += sr.acmrBefore * sr.triangleCount; + aggregate.weightedAcmrAfter += sr.acmrAfter * sr.triangleCount; + if (sr.reordered) ++aggregate.totalReordered; + } +} + +void VertexCacheOptimizer::finalize(VertexCacheReport& report) +{ + if (report.totalTriangles > 0) { + report.weightedAcmrBefore /= report.totalTriangles; + report.weightedAcmrAfter /= report.totalTriangles; + } +} + VertexCacheReport VertexCacheOptimizer::analyzeEntity(Ogre::Entity* entity, bool rewrite) { VertexCacheReport report; @@ -219,70 +365,12 @@ VertexCacheReport VertexCacheOptimizer::analyzeEntity(Ogre::Entity* entity, bool const QString meshName = QString::fromStdString(mesh->getName()); for (unsigned si = 0; si < mesh->getNumSubMeshes(); ++si) { - Ogre::SubMesh* sub = mesh->getSubMesh(si); - if (!sub) continue; - Ogre::IndexData* id = sub->indexData; - if (!id || !id->indexBuffer || id->indexCount < 3 || id->indexCount % 3 != 0) continue; - - const bool use16 = (id->indexBuffer->getType() == Ogre::HardwareIndexBuffer::IT_16BIT); - - // Copy the index buffer into a uint32 vector so the optimizer can work - // in a single uniform format. - std::vector indices(id->indexCount); - { - const void* src = id->indexBuffer->lock(Ogre::HardwareBuffer::HBL_READ_ONLY); - if (use16) { - const auto* in = static_cast(src); - for (size_t i = 0; i < id->indexCount; ++i) indices[i] = in[id->indexStart + i]; - } else { - const auto* in = static_cast(src); - for (size_t i = 0; i < id->indexCount; ++i) indices[i] = in[id->indexStart + i]; - } - id->indexBuffer->unlock(); - } - - Ogre::VertexData* vd = sub->useSharedVertices ? mesh->sharedVertexData : sub->vertexData; - const uint32_t vertexCount = vd ? static_cast(vd->vertexCount) : 0; - SubMeshCacheReport sr; sr.meshName = meshName; - sr.submeshIndex = static_cast(si); - sr.triangleCount = static_cast(indices.size() / 3); - sr.acmrBefore = computeAcmr(indices); - - // Always run Forsyth on a local copy so we know what the optimized - // ACMR would be. This lets the validator / analyze-only mode report - // the projected improvement without mutating the index buffer. - // `reordered` stays false unless we actually write back below. - if (vertexCount > 0) { - std::vector reordered = indices; - if (forsyth(reordered, vertexCount)) { - sr.acmrAfter = computeAcmr(reordered); - - // Only write back when the caller asked AND the reorder - // improves ACMR — never regress. - if (rewrite && sr.acmrAfter < sr.acmrBefore) { - void* dst = id->indexBuffer->lock(Ogre::HardwareBuffer::HBL_NORMAL); - if (use16) { - auto* out = static_cast(dst); - for (size_t i = 0; i < reordered.size(); ++i) - out[id->indexStart + i] = static_cast(reordered[i]); - } else { - auto* out = static_cast(dst); - for (size_t i = 0; i < reordered.size(); ++i) - out[id->indexStart + i] = reordered[i]; - } - id->indexBuffer->unlock(); - sr.reordered = true; - ++report.totalReordered; - } - } else { - sr.acmrAfter = sr.acmrBefore; - } - } else { - sr.acmrAfter = sr.acmrBefore; - } + analyzeSubMesh(mesh, si, rewrite, sr); + if (sr.triangleCount == 0) continue; + if (sr.reordered) ++report.totalReordered; report.submeshes.append(sr); report.totalTriangles += sr.triangleCount; report.weightedAcmrBefore += sr.acmrBefore * sr.triangleCount; diff --git a/src/VertexCacheOptimizer.h b/src/VertexCacheOptimizer.h index 435e52ded..198b5f63e 100644 --- a/src/VertexCacheOptimizer.h +++ b/src/VertexCacheOptimizer.h @@ -67,6 +67,16 @@ class VertexCacheOptimizer { // and reports — pure analysis. static VertexCacheReport analyzeEntity(Ogre::Entity* entity, bool rewrite); + // Merge `partial` into `aggregate` and recompute the running tri- + // weighted ACMR totals. Used by every caller that walks multiple + // entities (CLI cmdVertexCache + MCP toolOptimizeVertexCache). + static void mergeReport(VertexCacheReport& aggregate, + const VertexCacheReport& partial); + + // Final post-merge step: divide weighted-ACMR sums by total triangles. + // Idempotent on empty reports. + static void finalize(VertexCacheReport& report); + // Serialize a VertexCacheReport as JSON (CLI / MCP). static QJsonObject toJson(const VertexCacheReport& report); From 5d57cc68602514ea0fd0262dd9d4486c14e0c7c4 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 12 May 2026 16:49:32 -0400 Subject: [PATCH 8/9] docs: Phase 6 performance features (memory / analyze / vertex-cache) (#498) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds end-user documentation for slices A/B/C to the website DocsApp, mirroring the existing CmdSection / RuleCard conventions and adding a new Performance section that covers concepts and ties the three new CLI commands together. New NAV group "Performance" with four entries: - Concepts: a long-form explanation of GPU memory, draw calls, and ACMR — what each metric measures, how the validator computes it, and a "what numbers should I see?" table for ACMR (~0.5 optimal, >2 needs reorder). - memory: full CmdSection with synopsis, all flags (--budget / --token / --no-cloud), example text output, and a paragraph explaining the JSON shape returned by the CLI and the MCP get_memory_usage tool. - analyze: CmdSection with synopsis, example output, and the "Reading the report" note explaining what the After-merges number means alongside Draw calls. - vertex-cache: CmdSection covering analyze-only vs rewrite-and- export, the "never regresses" guarantee, the Inspector workflow with the Optimize Vertex Cache button, and the MCP rewrite arg. Scan Reference additions: - New "Performance Rules" subsection under scan-rules with a RuleCard for max_acmr. Documents the Assimp-vs-Ogre index-order discrepancy so users don't get confused when scan numbers don't match the in-app validator — the calibration suggestion is 1.5 on the scan side. Links back to Performance Concepts. - --max-acmr CLI flag added to the scan options table. - max_acmr added to the YAML schema example with a hint pointing at Performance Concepts. Build verified manually: 11 tags balance (6 closing + 5 self-closing), 15 / 15
tags balance. The local vite build refuses to run on Node 20.18 (needs ≥20.19) so the actual bundle gets verified by CI's website build. Co-Authored-By: Claude Opus 4.7 (1M context) --- website/src/DocsApp.jsx | 237 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 237 insertions(+) diff --git a/website/src/DocsApp.jsx b/website/src/DocsApp.jsx index 64bc926d6..b118f3954 100644 --- a/website/src/DocsApp.jsx +++ b/website/src/DocsApp.jsx @@ -18,6 +18,12 @@ const NAV = [ { id: 'cmd-pose', label: 'pose' }, { id: 'cmd-scan', label: 'scan' }, ]}, + { section: 'Performance', items: [ + { id: 'perf-overview', label: 'Concepts' }, + { id: 'cmd-memory', label: 'memory' }, + { id: 'cmd-analyze', label: 'analyze' }, + { id: 'cmd-vertex-cache', label: 'vertex-cache' }, + ]}, { section: 'Scan Reference', items: [ { id: 'scan-config', label: 'Configuration (qtmesh.yml)' }, { id: 'scan-rules', label: 'Rules Reference' }, @@ -409,6 +415,7 @@ qtmesh pose --animation --count N -o `} ['--min-materials ', 'Override min_material_count for this run (0 = no limit)'], ['--max-vertices ', 'Override max_vertex_count for this run (0 = no limit)'], ['--min-vertices ', 'Override min_vertex_count for this run (0 = no limit)'], + ['--max-acmr ', 'Override max_acmr for this run (e.g. 1.5; 0 = no limit)'], ['--require-skeleton / --no-require-skeleton', 'Enable/disable require_skeleton for this run'], ['--require-animations / --no-require-animations', 'Enable/disable require_animations for this run'], ['--allow-embedded-textures / --disallow-embedded-textures', 'Enable/disable embedded textures for this run'], @@ -456,6 +463,223 @@ Summary: ⏱ Time: 0.3s`} + {/* ─── Performance ─── */} + +
+

Performance Concepts

+

+ QtMeshEditor ships three first-class performance analyses, all accessible from the + CLI, the MCP server, and the Inspector's Run Validation checklist. + They never modify your source files unless you explicitly ask — analysis is + read-only by default, rewrites are opt-in. +

+ +

GPU memory (VRAM)

+

+ The bytes a mesh occupies on the GPU once it's resident, broken into two pieces: +

+
    +
  • Mesh bytes: vertexCount × stride + indexCount × indexSize. + Stride is determined by the vertex declaration (positions + normals + UVs + bone weights, etc.). + Index size is 2 bytes for 16-bit buffers and 4 bytes for 32-bit.
  • +
  • Texture bytes: width × height × bytesPerPixel, multiplied by + 4/3 when a mipmap chain is present (the chain converges to a third of the base).
  • +
+

+ The validator's GPU: row sums both, deduplicating shared meshes so totals reflect + actual GPU residents (not draw-call counts). qtmesh memory exposes the same numbers + from a file path. The --budget flag (or the memory_budget_mb rule on a + QtMesh Cloud project) flips the command's exit code to 1 when the scene exceeds the + configured ceiling — useful as a CI gate. +

+ +

Draw calls

+

+ One draw call per SubEntity. Ogre cannot batch SubEntities that share a material + into a single draw call automatically, so two cubes with the same wood material still cost two + calls. qtmesh analyze groups every loaded entity by the materials its SubEntities + use and reports two numbers: +

+
    +
  • Draw calls: today's cost.
  • +
  • After merges: the cost if all N entities sharing a material were + combined into one — saving N−1 calls per cluster.
  • +
+

+ The validator surfaces this as a Draws: info row with the merge-savings count. + The merge itself isn't done from the validator yet (it's a write op crossing the undo stack); + the suggestion in the JSON tells you which entities to combine in Edit Mode or via a future + qtmesh optimize command. +

+ +

Vertex cache (ACMR)

+

+ Modern GPUs cache the last ~32 transformed vertices (the "post-T&L cache"). Triangles that + reference recently-emitted vertices skip the per-vertex pipeline cost. ACMR + (Average Cache Miss Ratio) measures how friendly a mesh's index order is to that cache — + cache misses divided by triangle count. +

+ + + + + + + + +
ACMR rangeMeaning
~0.5Theoretical optimum (each triangle reuses two cached verts).
0.5 – 1.0Well-ordered strips; no reorder needed.
1.0 – 2.0Typical for unoptimized exporters. Worth a reorder pass.
> 2.0Random-ish topology. Reorder cuts vertex-shader load substantially.
+

+ qtmesh vertex-cache runs Tom Forsyth's linear-time algorithm + on every submesh. Without -o it's analyze-only and reports what the projected + ACMR would be after a reorder. With -o <out> it rewrites the index buffer in + place — but only when the new order strictly improves ACMR (never regresses). The Inspector's + Optimize Vertex Cache button does the same in-memory rewrite without writing back + to disk. +

+

+ The scan command's max_acmr rule flags assets above a configured ceiling. + Heads-up: scan computes ACMR from Assimp's flattened triangle list, which doesn't match + Ogre's index order — scan numbers run higher than the in-app validator's on the same asset. + 1.5 is a reasonable scan-side ceiling. A future Ogre-backed scan backend will + reconcile the numbers. +

+
+ + [--json] [--budget ] [--token ] [--no-cloud]`} + options={[ + ['--json', 'Output the structured report as JSON'], + ['--budget ', 'Memory ceiling. Accepts plain bytes or units: 50MB, 1.5GB, 2048KB. Exit 1 if exceeded.'], + ['--token ', 'QtMesh Cloud ingest token. When set and --budget is omitted, the project\'s memory_budget_mb rule is fetched and used.'], + ['--no-cloud', 'Opt out of the cloud-budget lookup (use the local default).'], + ]} + examples={[ + 'qtmesh memory character.fbx', + 'qtmesh memory character.fbx --json', + 'qtmesh memory character.fbx --budget 50MB', + 'qtmesh memory character.fbx --budget 1.5GB --json', + 'QTMESH_TOKEN=… qtmesh memory character.fbx', + ]} + > +

Example Output

+ {`Memory Report +============= + +Meshes (1): + character.mesh v=12,584 i=58,002 524.3 KB + TOTAL: 524.3 KB + +Textures (3): + diffuse.png 1024x1024 4Bpp +mips 5.33 MB + normal.png 1024x1024 4Bpp +mips 5.33 MB + metallicRough.png 512x512 4Bpp +mips 1.33 MB + TOTAL: 12.00 MB + +Scene total: 12.51 MB +Budget: 50.00 MB`} +

JSON shape

+

+ The JSON payload has meshes[], textures[], a totals + object with meshBytes/textureBytes/totalBytes, and (when + a budget is set) a budget object with bytes and overBudget. + The same shape comes back from the MCP get_memory_usage tool under the + memory key. +

+
+ + [--json]`} + options={[ + ['--json', 'Output the structured report as JSON'], + ]} + examples={[ + 'qtmesh analyze level_environment.glb', + 'qtmesh analyze level_environment.glb --json', + ]} + > +

Example Output

+ {`Draw Call Analysis +================== + +Entities: 12 +Submeshes: 18 +Draw calls: 18 +Unique mats: 5 +After merges: 8 (saves 10) + +Materials: + Foliage submeshes=4 entities=4 + Stone.Wall submeshes=6 entities=5 + Stone.Floor submeshes=4 entities=2 + Lantern submeshes=2 entities=1 + Water submeshes=2 entities=0 + +Merge suggestions (saves >0 draw calls): + Stone.Wall merge 5 entities → save 4 draw calls + - wall_north + - wall_south + - wall_west_a + - wall_west_b + - wall_east + Foliage merge 4 entities → save 3 draw calls + - tree_1 + - tree_2 + - bush_a + - bush_b + Stone.Floor merge 2 entities → save 1 draw calls + - floor_main + - floor_alcove`} +

+ Reading the report: "Draw calls" is today's cost. "After merges" is the cost + if every shared-material cluster were combined. The gap is what you'd save with batching. + In the validator, the Draws: info row shows the same totals; in MCP the + analyze_draw_calls tool returns the structured payload under the + drawCalls key. +

+
+ + [-o ] [--json]`} + options={[ + ['-o ', 'Output file. When omitted, runs in analyze-only mode (index buffer is not modified).'], + ['--json', 'Output the structured report as JSON'], + ]} + examples={[ + 'qtmesh vertex-cache character.fbx # analyze-only', + 'qtmesh vertex-cache character.fbx -o optimized.fbx # rewrite + export', + 'qtmesh vertex-cache character.fbx --json', + ]} + > +

Example Output

+ {`File: ninja.mesh -> ninja_opt.mesh +Vertex Cache Analysis +===================== + + ninja.mesh [0] tris=904 ACMR 0.971 → 0.871 (reordered) + ninja.mesh [1] tris=104 ACMR 0.587 → 0.587 + +Total triangles: 1,008 +Weighted ACMR: 0.932 → 0.841 (9.7% improvement) +Submeshes rewritten: 1 of 2`} +

+ Never regresses: the rewrite only happens when the new ACMR is strictly + lower than the original. Submesh 1 above is already near-optimal (0.587), so it's not + rewritten — the "analyze-only" column matches the "after" column for it. +

+

+ Inspector workflow: click Run Validation. If the cache row + reports a meaningful improvement (≥1%), an Optimize Vertex Cache button appears. + That button runs the reorder on Ogre's in-memory index buffer without writing to disk — + export the scene to persist. The Inspector also shows the projected ACMR delta on the row + even before you click. +

+

+ MCP: the optimize_vertex_cache tool takes a rewrite + bool (default false) and returns the structured payload under the + vertexCache key. +

+
+ {/* ─── Scan Reference ─── */}
@@ -495,6 +719,9 @@ rules: max_vertex_count: 100000 min_vertex_count: 3 # Catch degenerate geometry + # Vertex-cache friendliness (ACMR — see Performance Concepts) + max_acmr: 1.5 # Warn when ACMR exceeds this; 0 = disabled + # Skeleton & animation existence require_skeleton: false require_animations: false @@ -571,6 +798,16 @@ report: ))} +

Performance Rules

+ Maximum acceptable Average Cache Miss Ratio. Flags meshes whose + index buffer reorders poorly for the GPU vertex cache. See the Performance Concepts page + for the formula. Note: scan computes ACMR from Assimp's flattened triangle list, not + Ogre's index order, so the numbers run higher than the in-app validator on the same asset — + 1.5 is a reasonable starting ceiling on the scan side. Fix with + qtmesh vertex-cache <in> -o <out>.} + example={`max_acmr: 1.5 # 0 = disabled`} /> +

Skeleton & Animation Existence

Date: Tue, 12 May 2026 17:07:28 -0400 Subject: [PATCH 9/9] chore(perf): const-pointers on slice C helpers (#498) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clears 4 of the 6 remaining S5350 / S995 minors flagged by Sonar after the post-merge attribution: - CLIPipeline::cmdVertexCache: `entities` reference is now `const auto&` (the loop only reads; no add/remove). - exportRewrittenMesh: `entity` / `node` are now pointer-to-const (MeshImporterExporter::exporter takes const SceneNode* already, the read-only path is straight-through). - VertexCacheOptimizer::readIndexBuffer: takes `const IndexData*` (locks read-only). The remaining S995 on writeIndexBuffer/analyzeEntity are not really fixable: both functions exist specifically to mutate Ogre buffers through the supplied IndexData* / Entity* pointer when rewrite=true. A pointer-to-const there would force a const_cast inside or change the API contract — left as-is. The MAJOR S5817 on toolOptimizeVertexCache stays NOSONAR'd for the same reason as the slice A / B tool methods: ToolHandler is a non-const member-fn pointer. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/CLIPipeline.cpp | 6 +++--- src/VertexCacheOptimizer.cpp | 5 +++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index 296ec36cf..2cffe0831 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -3516,8 +3516,8 @@ bool parseVertexCacheArgs(int argc, char* argv[], VertexCacheCmdArgs& out) int exportRewrittenMesh(const QList& entities, const QFileInfo& srcFi, const QString& outputPath) { - Ogre::Entity* entity = entities.first(); - auto* node = entity->getParentSceneNode(); + const Ogre::Entity* entity = entities.first(); + const auto* node = entity->getParentSceneNode(); const QFileInfo outFi(outputPath); const QString fmt = CLIPipeline::formatForExtension(outputPath); SentryReporter::addBreadcrumb("file.export", @@ -3576,7 +3576,7 @@ int CLIPipeline::cmdVertexCache(int argc, char* argv[]) QString("Importing %1").arg(fi.absoluteFilePath())); MeshImporterExporter::importer({fi.absoluteFilePath()}, 0); - auto& entities = Manager::getSingleton()->getEntities(); + const auto& entities = Manager::getSingleton()->getEntities(); if (entities.isEmpty()) { err() << "Error: Failed to load file: " << cmdArgs.filePath << Qt::endl; return 1; diff --git a/src/VertexCacheOptimizer.cpp b/src/VertexCacheOptimizer.cpp index 386b51795..58760c435 100644 --- a/src/VertexCacheOptimizer.cpp +++ b/src/VertexCacheOptimizer.cpp @@ -260,8 +260,9 @@ double VertexCacheOptimizer::computeAcmr(const std::vector& indices, i namespace { // Read an Ogre IndexData into a uint32 vector. Handles 16/32-bit index types -// transparently so the optimizer sees a single uniform format. -std::vector readIndexBuffer(Ogre::IndexData* id) +// transparently so the optimizer sees a single uniform format. The buffer is +// locked HBL_READ_ONLY, so a pointer-to-const is sufficient. +std::vector readIndexBuffer(const Ogre::IndexData* id) { std::vector indices(id->indexCount); const bool use16 = (id->indexBuffer->getType() == Ogre::HardwareIndexBuffer::IT_16BIT);