## Summary - Added LOD (Level of Detail) system for automatic geometry complexity reduction - Created texture audit and compression recommendation system - Integrated stats.js performance monitoring overlay - Added hardware tier detection and optimization - Created documentation for minimum hardware requirements ## Components 1. nexus/lod-manager.js - LOD system with distance-based detail switching 2. nexus/texture-auditor.js - Texture analysis and compression planning 3. nexus/performance-monitor.js - stats.js integration with custom panels 4. nexus/performance-integration.js - Unified performance system 5. tools/texture-audit-cli.js - Standalone texture audit CLI 6. docs/performance-hardware-requirements.md - Hardware requirements documentation ## Features - LOD levels for agent orbs and halos (3 detail levels) - Automatic LOD switching based on camera distance - Texture audit with VRAM usage analysis - Performance monitoring with FPS, draw calls, triangles - Hardware tier detection (High/Medium/Low) - Compression recommendations for textures ## Testing - LOD system creates proper geometry complexity levels - Texture audit identifies performance issues - Performance monitor displays real-time stats - Hardware tier detection works on Apple Silicon ## Acceptance Criteria ✓ LOD for complex agent models ✓ Texture audit and compression recommendations ✓ Performance monitoring with stats.js ✓ Hardware requirements documentation Issue: #873
255 lines
7.8 KiB
JavaScript
255 lines
7.8 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* ═══════════════════════════════════════════
|
|
* NEXUS TEXTURE AUDIT CLI — Standalone Audit Tool
|
|
* ═══════════════════════════════════════════
|
|
*
|
|
* Command-line tool to audit textures in the Nexus project.
|
|
* Provides compression recommendations and VRAM estimates.
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { execSync } = require('child_process');
|
|
|
|
// Configuration
|
|
const CONFIG = {
|
|
maxTextureSize: 2048,
|
|
maxTotalTextures: 50,
|
|
maxTotalVRAM: 256 * 1024 * 1024, // 256MB
|
|
textureExtensions: ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.basis', '.ktx2'],
|
|
imageMagickPath: 'convert', // Path to ImageMagick convert
|
|
};
|
|
|
|
class TextureAuditor {
|
|
constructor(projectRoot) {
|
|
this.projectRoot = projectRoot;
|
|
this.textureFiles = [];
|
|
this.auditResults = [];
|
|
this.totalVRAM = 0;
|
|
}
|
|
|
|
/**
|
|
* Scan project for texture files
|
|
*/
|
|
scanForTextures() {
|
|
console.log(`Scanning ${this.projectRoot} for textures...`);
|
|
|
|
const scanDir = (dir) => {
|
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(dir, entry.name);
|
|
|
|
if (entry.isDirectory()) {
|
|
// Skip node_modules and .git
|
|
if (entry.name !== 'node_modules' && entry.name !== '.git') {
|
|
scanDir(fullPath);
|
|
}
|
|
} else if (entry.isFile()) {
|
|
const ext = path.extname(entry.name).toLowerCase();
|
|
if (CONFIG.textureExtensions.includes(ext)) {
|
|
this.textureFiles.push(fullPath);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
scanDir(this.projectRoot);
|
|
console.log(`Found ${this.textureFiles.length} texture files`);
|
|
return this.textureFiles;
|
|
}
|
|
|
|
/**
|
|
* Audit a single texture file
|
|
*/
|
|
auditTexture(filePath) {
|
|
const result = {
|
|
file: path.relative(this.projectRoot, filePath),
|
|
issues: [],
|
|
recommendations: [],
|
|
score: 100,
|
|
};
|
|
|
|
try {
|
|
const stats = fs.statSync(filePath);
|
|
const fileSize = stats.size;
|
|
const ext = path.extname(filePath).toLowerCase();
|
|
|
|
// Get image dimensions if possible
|
|
let width = 0;
|
|
let height = 0;
|
|
|
|
try {
|
|
const identify = execSync(`${CONFIG.imageMagickPath} -format "%wx%h" "${filePath}"`, { encoding: 'utf8' });
|
|
const match = identify.match(/(\d+)x(\d+)/);
|
|
if (match) {
|
|
width = parseInt(match[1]);
|
|
height = parseInt(match[2]);
|
|
}
|
|
} catch (e) {
|
|
// ImageMagick not available, skip dimension check
|
|
}
|
|
|
|
// Calculate estimated VRAM (RGBA)
|
|
const vram = width * height * 4;
|
|
this.totalVRAM += vram;
|
|
|
|
// Check file size
|
|
if (fileSize > 10 * 1024 * 1024) { // >10MB
|
|
result.issues.push(`Large file size: ${(fileSize / 1024 / 1024).toFixed(1)}MB`);
|
|
result.recommendations.push('Consider compressing or using a different format');
|
|
result.score -= 20;
|
|
}
|
|
|
|
// Check dimensions
|
|
if (width > CONFIG.maxTextureSize || height > CONFIG.maxTextureSize) {
|
|
result.issues.push(`Texture too large: ${width}x${height} (max: ${CONFIG.maxTextureSize}x${CONFIG.maxTextureSize})`);
|
|
result.recommendations.push(`Resize to ${CONFIG.maxTextureSize}x${CONFIG.maxTextureSize} or smaller`);
|
|
result.score -= 30;
|
|
}
|
|
|
|
// Check if power of two
|
|
if (width > 0 && height > 0) {
|
|
if (!this.isPowerOfTwo(width) || !this.isPowerOfTwo(height)) {
|
|
result.issues.push('Texture dimensions not power of two');
|
|
result.recommendations.push('Resize to nearest power of two (e.g., 512x512, 1024x1024)');
|
|
result.score -= 15;
|
|
}
|
|
}
|
|
|
|
// Check format
|
|
if (ext === '.png' || ext === '.jpg' || ext === '.jpeg') {
|
|
result.recommendations.push('Consider using WebP for better compression');
|
|
result.score -= 10;
|
|
}
|
|
|
|
// Check VRAM usage
|
|
if (vram > 16 * 1024 * 1024) { // >16MB
|
|
result.issues.push(`High VRAM usage: ${(vram / 1024 / 1024).toFixed(1)}MB`);
|
|
result.recommendations.push('Use compressed texture format (WebP, Basis, or KTX2)');
|
|
result.score -= 20;
|
|
}
|
|
|
|
} catch (error) {
|
|
result.issues.push(`Error reading file: ${error.message}`);
|
|
result.score = 0;
|
|
}
|
|
|
|
result.score = Math.max(0, result.score);
|
|
this.auditResults.push(result);
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Run full audit
|
|
*/
|
|
audit() {
|
|
this.scanForTextures();
|
|
|
|
console.log('\n=== Texture Audit Results ===\n');
|
|
|
|
let totalScore = 0;
|
|
let issuesFound = 0;
|
|
|
|
for (const file of this.textureFiles) {
|
|
const result = this.auditTexture(file);
|
|
totalScore += result.score;
|
|
issuesFound += result.issues.length;
|
|
|
|
if (result.issues.length > 0) {
|
|
console.log(`\n${result.file}:`);
|
|
console.log(` Score: ${result.score}/100`);
|
|
result.issues.forEach(issue => console.log(` ⚠️ ${issue}`));
|
|
result.recommendations.forEach(rec => console.log(` 💡 ${rec}`));
|
|
}
|
|
}
|
|
|
|
// Summary
|
|
console.log('\n=== Audit Summary ===');
|
|
console.log(`Total textures: ${this.textureFiles.length}`);
|
|
console.log(`Total VRAM: ${(this.totalVRAM / 1024 / 1024).toFixed(1)}MB`);
|
|
console.log(`Average score: ${(totalScore / this.textureFiles.length).toFixed(1)}/100`);
|
|
console.log(`Issues found: ${issuesFound}`);
|
|
console.log(`Texture limit: ${this.textureFiles.length > CONFIG.maxTotalTextures ? 'EXCEEDED' : 'OK'}`);
|
|
console.log(`VRAM limit: ${this.totalVRAM > CONFIG.maxTotalVRAM ? 'EXCEEDED' : 'OK'}`);
|
|
|
|
// Generate compression plan
|
|
this.generateCompressionPlan();
|
|
}
|
|
|
|
/**
|
|
* Generate compression plan
|
|
*/
|
|
generateCompressionPlan() {
|
|
console.log('\n=== Compression Plan ===');
|
|
|
|
const webpCandidates = [];
|
|
const resizeCandidates = [];
|
|
|
|
for (const result of this.auditResults) {
|
|
if (result.score < 80) {
|
|
const ext = path.extname(result.file).toLowerCase();
|
|
|
|
if (ext === '.png' || ext === '.jpg' || ext === '.jpeg') {
|
|
webpCandidates.push(result.file);
|
|
}
|
|
|
|
if (result.issues.some(i => i.includes('too large'))) {
|
|
resizeCandidates.push(result.file);
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log(`Textures to convert to WebP: ${webpCandidates.length}`);
|
|
webpCandidates.forEach(f => console.log(` 📦 ${f}`));
|
|
|
|
console.log(`Textures to resize: ${resizeCandidates.length}`);
|
|
resizeCandidates.forEach(f => console.log(` 📐 ${f}`));
|
|
|
|
if (webpCandidates.length > 0) {
|
|
console.log('\nTo convert to WebP:');
|
|
console.log(' for file in *.png; do cwebp -q 80 "$file" -o "${file%.png}.webp"; done');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if number is power of two
|
|
*/
|
|
isPowerOfTwo(n) {
|
|
return n !== 0 && (n & (n - 1)) === 0;
|
|
}
|
|
|
|
/**
|
|
* Save audit results to JSON
|
|
*/
|
|
saveResults(outputPath) {
|
|
const report = {
|
|
timestamp: new Date().toISOString(),
|
|
projectRoot: this.projectRoot,
|
|
totalTextures: this.textureFiles.length,
|
|
totalVRAM: this.totalVRAM,
|
|
averageScore: this.auditResults.reduce((sum, r) => sum + r.score, 0) / this.auditResults.length,
|
|
results: this.auditResults,
|
|
};
|
|
|
|
fs.writeFileSync(outputPath, JSON.stringify(report, null, 2));
|
|
console.log(`\nAudit results saved to: ${outputPath}`);
|
|
}
|
|
}
|
|
|
|
// CLI interface
|
|
if (require.main === module) {
|
|
const projectRoot = process.argv[2] || process.cwd();
|
|
const auditor = new TextureAuditor(projectRoot);
|
|
|
|
auditor.audit();
|
|
|
|
// Save results if output path provided
|
|
if (process.argv[3]) {
|
|
auditor.saveResults(process.argv[3]);
|
|
}
|
|
}
|
|
|
|
module.exports = TextureAuditor;
|