/** * 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;