- Add performance-monitor.js: stats.js overlay with FPS, frame time, draw calls, and agent LOD stats. Toggle with Shift+P. - Add lod-system-enhanced.js: THREE.LOD integration with tier-based mesh simplification (high/mid/low PBR materials), billboard sprites, frustum culling, and automatic performance tier detection. - Add texture-optimizer.js: WebP conversion, texture size limits by tier, mipmap control, memory budget tracking, and scene audit. - Add performance-benchmark.js: automated 10s benchmark with report generation and hardware requirement validation. - Add docs/MINIMUM_SOVEREIGN_HARDWARE.md: performance tiers, draw call budgets, and M1 Mac baseline requirements. - Update app.js: integrate PerformanceMonitor.update in game loop, pass renderer to LODSystem.init(). - Update index.html: include new performance scripts. Acceptance criteria: ✓ LOD for complex agent models (4 levels: high/mid/low/sprite) ✓ Texture audit utilities with compression recommendations ✓ Performance overlay showing frame times and draw calls ✓ Minimum sovereign hardware documentation Closes #873
264 lines
7.5 KiB
JavaScript
264 lines
7.5 KiB
JavaScript
/**
|
|
* Texture Optimizer for The Nexus
|
|
*
|
|
* Provides utilities for texture compression and optimization:
|
|
* - WebP fallback for browser compatibility
|
|
* - Texture size limits based on performance tier
|
|
* - Mipmap generation control
|
|
* - Memory budget tracking
|
|
*
|
|
* Usage:
|
|
* const tex = TextureOptimizer.load('./assets/texture.png');
|
|
* TextureOptimizer.compressTexture(tex, { maxSize: 512, format: 'webp' });
|
|
*/
|
|
|
|
const TextureOptimizer = (() => {
|
|
// Size limits by performance tier (max texture dimension)
|
|
const SIZE_LIMITS = {
|
|
low: 512,
|
|
medium: 1024,
|
|
high: 2048
|
|
};
|
|
|
|
// Memory budget in MB
|
|
const MEMORY_BUDGETS = {
|
|
low: 64,
|
|
medium: 128,
|
|
high: 256
|
|
};
|
|
|
|
let _currentTier = 'medium';
|
|
let _textureMemory = 0;
|
|
let _textures = new Set();
|
|
|
|
function detectTier() {
|
|
const gl = document.createElement('canvas').getContext('webgl');
|
|
if (!gl) return 'low';
|
|
|
|
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
|
|
if (!debugInfo) return 'medium';
|
|
|
|
const renderer = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL);
|
|
|
|
if (renderer.includes('Apple')) {
|
|
return renderer.includes('M3') || renderer.includes('M2 Pro') || renderer.includes('M1 Pro')
|
|
? 'high' : 'medium';
|
|
}
|
|
if (renderer.includes('Intel') && !renderer.includes('Arc')) return 'low';
|
|
if (/Android|webOS|iPhone|iPad|iPod/i.test(navigator.userAgent)) return 'low';
|
|
|
|
return 'high';
|
|
}
|
|
|
|
function init() {
|
|
_currentTier = detectTier();
|
|
console.log(`[TextureOptimizer] Tier: ${_currentTier}, Max texture size: ${SIZE_LIMITS[_currentTier]}px`);
|
|
}
|
|
|
|
/**
|
|
* Load a texture with automatic optimization
|
|
*/
|
|
function load(url, options = {}) {
|
|
const loader = new THREE.TextureLoader();
|
|
const texture = loader.load(url, (tex) => {
|
|
optimizeTexture(tex, options);
|
|
});
|
|
|
|
_textures.add(texture);
|
|
return texture;
|
|
}
|
|
|
|
/**
|
|
* Optimize an existing texture
|
|
*/
|
|
function optimizeTexture(texture, options = {}) {
|
|
const maxSize = options.maxSize || SIZE_LIMITS[_currentTier];
|
|
const format = options.format || 'auto';
|
|
|
|
// Limit texture size
|
|
const width = texture.image?.width || texture.image?.videoWidth || 0;
|
|
const height = texture.image?.height || texture.image?.videoHeight || 0;
|
|
|
|
if (width > maxSize || height > maxSize) {
|
|
const scale = maxSize / Math.max(width, height);
|
|
const newWidth = Math.floor(width * scale);
|
|
const newHeight = Math.floor(height * scale);
|
|
|
|
// Create resized canvas
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = newWidth;
|
|
canvas.height = newHeight;
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.drawImage(texture.image, 0, 0, newWidth, newHeight);
|
|
|
|
texture.image = canvas;
|
|
texture.needsUpdate = true;
|
|
|
|
console.log(`[TextureOptimizer] Resized texture from ${width}x${height} to ${newWidth}x${newHeight}`);
|
|
}
|
|
|
|
// Mipmap settings based on tier
|
|
if (_currentTier === 'low') {
|
|
texture.generateMipmaps = false;
|
|
texture.minFilter = THREE.LinearFilter;
|
|
} else if (_currentTier === 'medium') {
|
|
texture.generateMipmaps = true;
|
|
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
|
}
|
|
|
|
// Anisotropic filtering
|
|
if (options.anisotropy !== undefined) {
|
|
texture.anisotropy = options.anisotropy;
|
|
} else if (_currentTier === 'low') {
|
|
texture.anisotropy = 1;
|
|
} else if (_currentTier === 'medium') {
|
|
texture.anisotropy = 4;
|
|
} else {
|
|
texture.anisotropy = 8;
|
|
}
|
|
|
|
// Format conversion hint (for server-side preprocessing)
|
|
if (format === 'webp' && !urlEndsWith(texture.image?.src, '.webp')) {
|
|
console.warn(`[TextureOptimizer] Consider converting to WebP: ${texture.image?.src}`);
|
|
}
|
|
|
|
// Track memory usage
|
|
trackMemory(texture);
|
|
|
|
return texture;
|
|
}
|
|
|
|
function urlEndsWith(url, suffix) {
|
|
return url && url.toLowerCase().endsWith(suffix);
|
|
}
|
|
|
|
function trackMemory(texture) {
|
|
const width = texture.image?.width || 0;
|
|
const height = texture.image?.height || 0;
|
|
const bytesPerPixel = 4; // RGBA
|
|
const mipmaps = texture.generateMipmaps ? 1.33 : 1; // Mipmaps add ~33%
|
|
const memoryMB = (width * height * bytesPerPixel * mipmaps) / (1024 * 1024);
|
|
|
|
_textureMemory += memoryMB;
|
|
texture.userData.memoryMB = memoryMB;
|
|
|
|
const budget = MEMORY_BUDGETS[_currentTier];
|
|
if (_textureMemory > budget) {
|
|
console.warn(`[TextureOptimizer] Memory budget exceeded: ${_textureMemory.toFixed(1)}MB / ${budget}MB`);
|
|
cleanupTextures();
|
|
}
|
|
}
|
|
|
|
function cleanupTextures() {
|
|
const budget = MEMORY_BUDGETS[_currentTier];
|
|
if (_textureMemory <= budget) return;
|
|
|
|
// Sort by last used time (if available)
|
|
const sorted = Array.from(_textures).sort((a, b) => {
|
|
return (a.userData.lastUsed || 0) - (b.userData.lastUsed || 0);
|
|
});
|
|
|
|
for (const tex of sorted) {
|
|
if (_textureMemory <= budget * 0.8) break;
|
|
|
|
if (tex.userData.memoryMB) {
|
|
_textureMemory -= tex.userData.memoryMB;
|
|
tex.dispose();
|
|
_textures.delete(tex);
|
|
}
|
|
}
|
|
|
|
console.log(`[TextureOptimizer] Cleaned up textures. Memory: ${_textureMemory.toFixed(1)}MB`);
|
|
}
|
|
|
|
function getMemoryUsage() {
|
|
return {
|
|
used: _textureMemory,
|
|
budget: MEMORY_BUDGETS[_currentTier],
|
|
count: _textures.size
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Audit all textures in a scene
|
|
*/
|
|
function auditScene(scene) {
|
|
const audit = {
|
|
textures: [],
|
|
totalMemory: 0,
|
|
oversized: [],
|
|
uncompressed: []
|
|
};
|
|
|
|
scene.traverse((object) => {
|
|
if (object.material) {
|
|
const materials = Array.isArray(object.material) ? object.material : [object.material];
|
|
materials.forEach(mat => {
|
|
['map', 'normalMap', 'roughnessMap', 'metalnessMap', 'emissiveMap', 'aoMap'].forEach(mapType => {
|
|
const texture = mat[mapType];
|
|
if (texture && texture.image) {
|
|
const width = texture.image.width || 0;
|
|
const height = texture.image.height || 0;
|
|
const memoryMB = (width * height * 4) / (1024 * 1024);
|
|
|
|
audit.textures.push({
|
|
type: mapType,
|
|
size: `${width}x${height}`,
|
|
memoryMB: memoryMB.toFixed(2),
|
|
url: texture.image.src || 'generated'
|
|
});
|
|
|
|
audit.totalMemory += memoryMB;
|
|
|
|
if (width > SIZE_LIMITS[_currentTier] || height > SIZE_LIMITS[_currentTier]) {
|
|
audit.oversized.push({
|
|
type: mapType,
|
|
size: `${width}x${height}`,
|
|
limit: SIZE_LIMITS[_currentTier]
|
|
});
|
|
}
|
|
|
|
const src = texture.image.src || '';
|
|
if (src && !src.endsWith('.webp') && !src.endsWith('.ktx2') && !src.endsWith('.basis')) {
|
|
audit.uncompressed.push(src);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
});
|
|
|
|
return audit;
|
|
}
|
|
|
|
/**
|
|
* Convert image to WebP (client-side, for runtime generated textures)
|
|
*/
|
|
async function convertToWebP(imageElement, quality = 0.85) {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = imageElement.width;
|
|
canvas.height = imageElement.height;
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.drawImage(imageElement, 0, 0);
|
|
|
|
return new Promise((resolve) => {
|
|
canvas.toBlob((blob) => {
|
|
resolve(blob);
|
|
}, 'image/webp', quality);
|
|
});
|
|
}
|
|
|
|
return {
|
|
init,
|
|
load,
|
|
optimizeTexture,
|
|
getMemoryUsage,
|
|
auditScene,
|
|
convertToWebP,
|
|
SIZE_LIMITS,
|
|
MEMORY_BUDGETS
|
|
};
|
|
})();
|
|
|
|
window.TextureOptimizer = TextureOptimizer;
|