diff --git a/qml/BottomContextPanel.qml b/qml/BottomContextPanel.qml index 3e3648349..94b2963c2 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,7 +215,10 @@ Rectangle { SummaryText { label: "Selection"; value: MeshValidator.hasSelection ? PropertiesPanelController.selectionName : "None" } SummaryText { label: "Findings"; value: root.issueSummary() } - SummaryText { label: "Fixable"; value: MeshValidator.hasFixableIssues ? "Yes" : "No" } + SummaryText { label: "Suggestions"; value: root.suggestionSummary() } + 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/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index ca43fe051..cd2809403 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 @@ -2584,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/CLIPipeline.cpp b/src/CLIPipeline.cpp index 844a89559..2cffe0831 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 @@ -569,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" @@ -611,6 +613,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 +980,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; @@ -2761,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; @@ -2906,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) { @@ -3064,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; @@ -3473,3 +3487,113 @@ int CLIPipeline::cmdAnalyze(int argc, char* argv[]) } return 0; } + +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") { 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(); +} + +// 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) +{ + 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", + 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(cmdArgs.filePath); + if (!fi.exists()) { + err() << "Error: File not found: " << cmdArgs.filePath << Qt::endl; + return 1; + } + + if (!initOgreHeadless()) return 1; + + 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", + QString("Importing %1").arg(fi.absoluteFilePath())); + + MeshImporterExporter::importer({fi.absoluteFilePath()}, 0); + const auto& entities = Manager::getSingleton()->getEntities(); + if (entities.isEmpty()) { + err() << "Error: Failed to load file: " << cmdArgs.filePath << Qt::endl; + return 1; + } + + VertexCacheReport aggregate; + for (Ogre::Entity* entity : entities) { + VertexCacheOptimizer::mergeReport( + aggregate, VertexCacheOptimizer::analyzeEntity(entity, rewrite)); + } + VertexCacheOptimizer::finalize(aggregate); + + if (rewrite) { + if (const int rc = exportRewrittenMesh(entities, fi, cmdArgs.outputPath); rc != 0) + return rc; + } + + emitVertexCacheReport(aggregate, fi, cmdArgs.outputPath, cmdArgs.jsonOutput); + 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..33f9ae3f2 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,41 @@ 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 (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); + VertexCacheOptimizer::mergeReport( + aggregate, VertexCacheOptimizer::analyzeEntity(entity, rewrite)); + } + } + VertexCacheOptimizer::finalize(aggregate); + + 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 +4193,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..ee5234443 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 @@ -55,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(); @@ -77,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; @@ -149,6 +157,7 @@ void MeshValidator::doValidate() { m_issues.clear(); m_validated = false; + m_cacheOptimizationAvailable = false; const QList targets = validationTargetEntities(); if (targets.isEmpty()) { @@ -360,7 +369,53 @@ 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; + + 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; + if (meaningfulGain) { + m_cacheOptimizationAvailable = true; + issue["type"] = "info"; + issue["description"] = + QString("Vertex cache: ACMR %1 → %2 (%3% improvement available — " + "click \"Optimize Vertex Cache\" below)") + .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; + 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) { @@ -431,3 +486,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 }; 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/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..b181d97f2 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 auto 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; diff --git a/src/VertexCacheOptimizer.cpp b/src/VertexCacheOptimizer.cpp new file mode 100644 index 000000000..58760c435 --- /dev/null +++ b/src/VertexCacheOptimizer.cpp @@ -0,0 +1,456 @@ +#include "VertexCacheOptimizer.h" + +#include +#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 { + 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 = static_cast(kMaxCachePos - i) + / static_cast(kMaxCachePos - 3); + cache[i] = std::pow(p, kCacheDecayPow); + } + } +}; +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 = 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, + int cacheSize) +{ + if (indices.empty() || indices.size() % 3 != 0 || vertexCount == 0) return false; + if (cacheSize > kMaxCachePos) cacheSize = kMaxCachePos; + + // 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; + } + + const size_t triangleCount = indices.size() / 3; + + std::vector valence; + std::vector triOffset; + std::vector triList; + buildTriangleAdjacency(indices, vertexCount, valence, triOffset, triList); + + // Per-vertex state. + std::vector cachePos(vertexCount, -1); + std::vector vScore(vertexCount, 0.0f); + for (uint32_t v = 0; v < vertexCount; ++v) + vScore[v] = vertexScore(-1, valence[v]); + + // Per-triangle state. + std::vector emitted(triangleCount, 0); + std::vector tScore(triangleCount, 0.0f); + for (size_t t = 0; t < triangleCount; ++t) + tScore[t] = triangleScore(t, indices, vScore); + + // LRU cache + output buffer. + std::vector cache; + 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; + for (size_t t = 0; t < triangleCount; ++t) { + if (tScore[t] > bestScore) { + bestScore = tScore[t]; + bestTri = static_cast(t); + } + } + } + + while (bestTri >= 0) { + 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); + + // 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 + 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 (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) + 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 + +namespace { + +// Read an Ogre IndexData into a uint32 vector. Handles 16/32-bit index types +// 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); + 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; + 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) { + SubMeshCacheReport sr; + sr.meshName = meshName; + 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; + 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..198b5f63e --- /dev/null +++ b/src/VertexCacheOptimizer.h @@ -0,0 +1,87 @@ +#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); + + // 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); + + // 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 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