Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions qml/PropertiesPanel.qml
Original file line number Diff line number Diff line change
Expand Up @@ -2535,9 +2535,11 @@ Rectangle {
Text {
text: modelData.type === "error" ? "\u2718"
: modelData.type === "warning" ? "\u26A0"
: modelData.type === "info" ? "\u2139"
: "\u2714"
color: modelData.type === "error" ? "#e05050"
: modelData.type === "warning" ? "#e0a030"
: modelData.type === "info" ? "#5090d0"
: "#60c060"
font.pixelSize: 13
anchors.verticalCenter: parent.verticalCenter
Expand Down
56 changes: 56 additions & 0 deletions src/CLIPipeline.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
#include "TextureChannelPacker.h"
#include "NormalMapGenerator.h"
#include "MemoryEstimator.h"
#include "DrawCallAnalyzer.h"
#include "QtMeshCloudClient.h"
#include <OgreMaterialSerializer.h>
#include <QApplication>
Expand Down Expand Up @@ -608,6 +609,8 @@ void CLIPipeline::printUsage()
" If --budget omitted and a token is set, the project's\n"
" memory_budget_mb is fetched from QtMesh Cloud rules.\n"
" --no-cloud opts out.\n"
" analyze <file> [--json] Analyze draw calls: per-material grouping plus\n"
" merge suggestions for entities sharing a material.\n"
"\n"
"Global options:\n"
" --help, -h Show this help\n"
Expand Down Expand Up @@ -971,6 +974,7 @@ int CLIPipeline::run(int argc, char* argv[])
else if (cmd == "pack-textures") rc = cmdPackTextures(argc, 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);

if (rc < 0) {
err() << "Error: Unknown command '" << cmd << "'" << Qt::endl;
Expand Down Expand Up @@ -3417,3 +3421,55 @@ int CLIPipeline::cmdMemory(int argc, char* argv[])
emitMemoryReport(report, fi, budgetSource, cmdArgs.jsonOutput);
return report.overBudget() ? 1 : 0;
}

int CLIPipeline::cmdAnalyze(int argc, char* argv[])
{
// Parse: analyze <file> [--json]
QString filePath;
bool jsonOutput = false;

for (int i = 1; i < argc; ++i) {
const QString arg(argv[i]);
if (arg == "analyze" || arg == "--cli") continue;
if (arg == "--json") { jsonOutput = true; continue; }
if (!arg.startsWith("-") && filePath.isEmpty()) filePath = arg;
}

if (filePath.isEmpty()) {
err() << "Error: No input file specified." << Qt::endl;
err() << "Usage: qtmesh analyze <file> [--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;

SentryReporter::addBreadcrumb("cli.analyze",
QString("Analyze .%1").arg(fi.suffix()));
SentryReporter::addBreadcrumb("file.import",
QString("Importing %1").arg(fi.absoluteFilePath()));

MeshImporterExporter::importer({fi.absoluteFilePath()}, 0);
const auto& entities = Manager::getSingleton()->getEntities();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (entities.isEmpty()) {
err() << "Error: Failed to load file: " << filePath << Qt::endl;
return 1;
}

const DrawCallReport report = DrawCallAnalyzer::analyze(entities);

if (jsonOutput) {
QJsonObject obj = DrawCallAnalyzer::toJson(report);
obj["file"] = fi.fileName();
cliWrite(QString::fromUtf8(QJsonDocument(obj).toJson(QJsonDocument::Indented)));
} else {
cliWrite(QString("File: %1\n").arg(fi.fileName()));
cliWrite(DrawCallAnalyzer::toText(report));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return 0;
}
3 changes: 3 additions & 0 deletions src/CLIPipeline.h
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ class CLIPipeline {
/// Phase 6 slice A: estimate GPU memory & VRAM for a mesh file
/// (per-submesh + per-texture, optional --json, optional --budget).
static int cmdMemory(int argc, char* argv[]);
/// Phase 6 slice B: analyze draw calls and surface merge opportunities
/// (per-material grouping, optional --json).
static int cmdAnalyze(int argc, char* argv[]);

/// Map file extension to MeshImporterExporter format string.
static QString formatForExtension(const QString& path);
Expand Down
2 changes: 2 additions & 0 deletions src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ MeshLodController.cpp
TextureChannelPacker.cpp
NormalMapGenerator.cpp
MemoryEstimator.cpp
DrawCallAnalyzer.cpp
MeshValidator.cpp
AIChatManager.cpp
WelcomeScreenController.cpp
Expand Down Expand Up @@ -167,6 +168,7 @@ MeshLodController.h
TextureChannelPacker.h
NormalMapGenerator.h
MemoryEstimator.h
DrawCallAnalyzer.h
MeshValidator.h
AIChatManager.h
WelcomeScreenController.h
Expand Down
202 changes: 202 additions & 0 deletions src/DrawCallAnalyzer.cpp
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>
Comment thread
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;
}
70 changes: 70 additions & 0 deletions src/DrawCallAnalyzer.h
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
Loading
Loading