-
Notifications
You must be signed in to change notification settings - Fork 1
feat(perf): draw-call analysis + merge suggestions (Phase 6 slice B) #497
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
c60fc5b
feat(perf): draw-call analysis + merge suggestions (Phase 6 slice B)
fernandotonon cc88b7e
refactor(perf): address CodeRabbit + SonarCloud findings on slice B (…
fernandotonon 552a811
feat(validate): per-check feedback rows + draw-call & memory analyses…
fernandotonon File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,202 @@ | ||
| #include "DrawCallAnalyzer.h" | ||
| #include "Manager.h" | ||
|
|
||
| #include <Ogre.h> | ||
| #include <OgreEntity.h> | ||
| #include <OgreSubEntity.h> | ||
| #include <OgreMaterial.h> | ||
|
|
||
| #include <QHash> | ||
| #include <QJsonArray> | ||
| #include <QSet> | ||
| #include <algorithm> | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| namespace { | ||
|
|
||
| QString materialNameOrPlaceholder(const Ogre::SubEntity* sub) | ||
| { | ||
| if (!sub) return QStringLiteral("(none)"); | ||
| const auto mat = sub->getMaterial(); | ||
| if (!mat) return QStringLiteral("(none)"); | ||
| return QString::fromStdString(mat->getName()); | ||
| } | ||
|
|
||
| } // namespace | ||
|
|
||
| DrawCallReport DrawCallAnalyzer::analyze(const QList<Ogre::Entity*>& entities) | ||
| { | ||
| DrawCallReport report; | ||
|
|
||
| // Hash<material name → cluster>. We accumulate in insertion order via a | ||
| // parallel list of keys so the output is stable across runs. | ||
| QHash<QString, MaterialCluster> byMaterial; | ||
| QStringList materialOrder; | ||
|
|
||
| for (const Ogre::Entity* entity : entities) { | ||
| if (!entity) continue; | ||
| report.totalEntities++; | ||
| const QString entityName = QString::fromStdString(entity->getName()); | ||
| const size_t numSubs = entity->getNumSubEntities(); | ||
| report.totalSubmeshes += static_cast<int>(numSubs); | ||
|
|
||
| // Per-entity material set: a single entity that has 3 submeshes all | ||
| // bound to the same material still costs 3 draw calls (Ogre cannot | ||
| // batch them), but the merge-suggestion grouping should count the | ||
| // entity once per unique material it uses. | ||
| QSet<QString> entityMaterials; | ||
| for (size_t i = 0; i < numSubs; ++i) { | ||
| const Ogre::SubEntity* sub = entity->getSubEntity(i); | ||
| const QString matName = materialNameOrPlaceholder(sub); | ||
| report.totalDrawCalls++; // one draw call per SubEntity | ||
|
|
||
| MaterialCluster& cluster = byMaterial[matName]; | ||
| if (!materialOrder.contains(matName)) { | ||
| materialOrder.append(matName); | ||
| cluster.materialName = matName; | ||
| } | ||
| cluster.submeshCount++; | ||
| entityMaterials.insert(matName); | ||
| } | ||
|
|
||
| // Now record entity-per-material membership (once per material). | ||
| for (const QString& matName : entityMaterials) { | ||
| MaterialCluster& cluster = byMaterial[matName]; | ||
| if (!cluster.entityNames.contains(entityName)) | ||
| cluster.entityNames.append(entityName); | ||
| } | ||
| } | ||
|
|
||
| report.uniqueMaterials = static_cast<int>(materialOrder.size()); | ||
| for (const QString& matName : materialOrder) | ||
| report.clusters.append(byMaterial.value(matName)); | ||
|
|
||
| report.suggestions = buildSuggestions(report.clusters); | ||
| for (const MergeSuggestion& s : report.suggestions) | ||
| report.totalSavings += s.estimatedSavings; | ||
|
|
||
| // Sort suggestions by savings (descending) so the most valuable merges | ||
| // surface first. Stable order on ties so output is deterministic. | ||
| std::stable_sort(report.suggestions.begin(), report.suggestions.end(), | ||
| [](const MergeSuggestion& a, const MergeSuggestion& b) { | ||
| return a.estimatedSavings > b.estimatedSavings; | ||
| }); | ||
|
|
||
| report.potentialDrawCalls = report.totalDrawCalls - report.totalSavings; | ||
| return report; | ||
| } | ||
|
|
||
| // LCOV_EXCL_START — exercised only when Ogre is initialised (skipped in unit tests). | ||
| DrawCallReport DrawCallAnalyzer::analyzeScene() | ||
| { | ||
| QList<Ogre::Entity*> entities; | ||
| Manager* mgr = Manager::getSingletonPtr(); | ||
| if (!mgr) return analyze(entities); | ||
|
|
||
| for (const Ogre::SceneNode* node : mgr->getSceneNodes()) { | ||
| if (!node) continue; | ||
| for (unsigned int i = 0; i < node->numAttachedObjects(); ++i) { | ||
| Ogre::MovableObject* obj = node->getAttachedObject(i); | ||
| if (!obj || obj->getMovableType() != "Entity") continue; | ||
| entities.append(static_cast<Ogre::Entity*>(obj)); | ||
| } | ||
| } | ||
| return analyze(entities); | ||
| } | ||
| // LCOV_EXCL_STOP | ||
|
|
||
| QList<MergeSuggestion> DrawCallAnalyzer::buildSuggestions( | ||
| const QList<MaterialCluster>& clusters, int minSharedEntities) | ||
| { | ||
| QList<MergeSuggestion> out; | ||
| for (const MaterialCluster& c : clusters) { | ||
| if (c.entityNames.size() < minSharedEntities) continue; | ||
| MergeSuggestion s; | ||
| s.materialName = c.materialName; | ||
| s.entityNames = c.entityNames; | ||
| s.estimatedSavings = c.mergeSavings(); | ||
| out.append(s); | ||
| } | ||
| return out; | ||
| } | ||
|
|
||
| QJsonObject DrawCallAnalyzer::toJson(const DrawCallReport& report) | ||
| { | ||
| QJsonObject obj; | ||
| QJsonObject totals; | ||
| totals["entities"] = report.totalEntities; | ||
| totals["submeshes"] = report.totalSubmeshes; | ||
| totals["drawCalls"] = report.totalDrawCalls; | ||
| totals["uniqueMaterials"] = report.uniqueMaterials; | ||
| totals["potentialDrawCalls"] = report.potentialDrawCalls; | ||
| totals["totalSavings"] = report.totalSavings; | ||
| obj["totals"] = totals; | ||
|
|
||
| QJsonArray clusters; | ||
| for (const MaterialCluster& c : report.clusters) { | ||
| QJsonObject co; | ||
| co["material"] = c.materialName; | ||
| co["submeshCount"] = c.submeshCount; | ||
| QJsonArray names; | ||
| for (const QString& n : c.entityNames) names.append(n); | ||
| co["entities"] = names; | ||
| co["mergeSavings"] = c.mergeSavings(); | ||
| clusters.append(co); | ||
| } | ||
| obj["clusters"] = clusters; | ||
|
|
||
| QJsonArray suggestions; | ||
| for (const MergeSuggestion& s : report.suggestions) { | ||
| QJsonObject so; | ||
| so["material"] = s.materialName; | ||
| so["estimatedSavings"] = s.estimatedSavings; | ||
| QJsonArray names; | ||
| for (const QString& n : s.entityNames) names.append(n); | ||
| so["entities"] = names; | ||
| suggestions.append(so); | ||
| } | ||
| obj["suggestions"] = suggestions; | ||
|
|
||
| return obj; | ||
| } | ||
|
|
||
| QString DrawCallAnalyzer::toText(const DrawCallReport& report) | ||
| { | ||
| QString out; | ||
| QTextStream s(&out); | ||
|
|
||
| s << "Draw Call Analysis\n"; | ||
| s << "==================\n\n"; | ||
|
|
||
| s << "Entities: " << report.totalEntities << "\n"; | ||
| s << "Submeshes: " << report.totalSubmeshes << "\n"; | ||
| s << "Draw calls: " << report.totalDrawCalls << "\n"; | ||
| s << "Unique mats: " << report.uniqueMaterials << "\n"; | ||
| s << "After merges: " << report.potentialDrawCalls | ||
| << " (saves " << report.totalSavings << ")\n\n"; | ||
|
|
||
| if (!report.clusters.isEmpty()) { | ||
| s << "Materials:\n"; | ||
| for (const MaterialCluster& c : report.clusters) { | ||
| s << " " << c.materialName | ||
| << " submeshes=" << c.submeshCount | ||
| << " entities=" << c.entityNames.size() << "\n"; | ||
| } | ||
| s << "\n"; | ||
| } | ||
|
|
||
| if (!report.suggestions.isEmpty()) { | ||
| s << "Merge suggestions (saves >0 draw calls):\n"; | ||
| for (const MergeSuggestion& sug : report.suggestions) { | ||
| s << " " << sug.materialName | ||
| << " merge " << sug.entityNames.size() | ||
| << " entities → save " << sug.estimatedSavings | ||
| << " draw calls\n"; | ||
| for (const QString& n : sug.entityNames) | ||
| s << " - " << n << "\n"; | ||
| } | ||
| } else if (report.totalEntities > 0) { | ||
| s << "No merge opportunities (each material is used by at most one entity).\n"; | ||
| } | ||
|
|
||
| return out; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| #ifndef DRAWCALLANALYZER_H | ||
| #define DRAWCALLANALYZER_H | ||
|
|
||
| #include <QString> | ||
| #include <QStringList> | ||
| #include <QList> | ||
| #include <QJsonObject> | ||
|
|
||
| namespace Ogre { | ||
| class Entity; | ||
| } | ||
|
|
||
| // One row per (material, entity-list) cluster. A draw call is counted per | ||
| // SubEntity that uses the material — see DrawCallAnalyzer::analyze for the | ||
| // counting rule. | ||
| struct MaterialCluster { | ||
| QString materialName; | ||
| int submeshCount = 0; // total SubEntities bound to this material | ||
| QStringList entityNames; // names of entities that use this material | ||
| // Merge potential: every additional entity past the first is a draw call | ||
| // we could save by merging — provided the geometry is compatible. | ||
| int mergeSavings() const { | ||
| return entityNames.isEmpty() ? 0 : static_cast<int>(entityNames.size()) - 1; | ||
| } | ||
| }; | ||
|
|
||
| struct MergeSuggestion { | ||
| QString materialName; | ||
| QStringList entityNames; | ||
| int estimatedSavings = 0; // draw calls saved if the merge is performed | ||
| }; | ||
|
|
||
| struct DrawCallReport { | ||
| int totalEntities = 0; | ||
| int totalSubmeshes = 0; // sum of SubEntity counts across entities | ||
| int totalDrawCalls = 0; // current estimated draw-call count | ||
| int uniqueMaterials = 0; | ||
| int potentialDrawCalls = 0; // draw calls if all viable merges happened | ||
| int totalSavings = 0; // totalDrawCalls - potentialDrawCalls | ||
| QList<MaterialCluster> clusters; | ||
| QList<MergeSuggestion> suggestions; | ||
| }; | ||
|
|
||
| // Pure-data analyzer. All methods are static and side-effect free. | ||
| class DrawCallAnalyzer { | ||
| public: | ||
| // Analyze a list of entities and produce a DrawCallReport. Null pointers | ||
| // in the input are skipped. The draw-call estimate counts one call per | ||
| // SubEntity (Ogre's coarsest granularity is the SubEntity render op). | ||
| static DrawCallReport analyze(const QList<Ogre::Entity*>& entities); | ||
|
|
||
| // Build a report for every entity currently attached under the scene | ||
| // root. Convenience wrapper. | ||
| static DrawCallReport analyzeScene(); | ||
|
|
||
| // Serialize the report as JSON (CLI / MCP). | ||
| static QJsonObject toJson(const DrawCallReport& report); | ||
|
|
||
| // Serialize the report as human-readable text (CLI default). | ||
| static QString toText(const DrawCallReport& report); | ||
|
|
||
| // Suggestion-list filter: only clusters with `>= minSharedEntities` | ||
| // entities are reported, so the noise of single-instance materials | ||
| // does not crowd the output. Default 2 (two entities = at least one | ||
| // draw call saved by a merge). | ||
| static QList<MergeSuggestion> buildSuggestions( | ||
| const QList<MaterialCluster>& clusters, int minSharedEntities = 2); | ||
| }; | ||
|
|
||
| #endif // DRAWCALLANALYZER_H |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.