/** * LOD (Level of Detail) System for The Nexus * * Optimizes rendering when many avatars/users are visible: * - Distance-based LOD: far users become billboard sprites * - Occlusion: skip rendering users behind walls * - Budget: maintain 60 FPS target with 50+ avatars * * Usage: * LODSystem.init(scene, camera); * LODSystem.registerAvatar(avatarMesh, userId); * LODSystem.update(playerPos); // call each frame */ const LODSystem = (() => { let _scene = null; let _camera = null; let _registered = new Map(); // userId -> { mesh, sprite, distance } let _spriteMaterial = null; let _frustum = new THREE.Frustum(); let _projScreenMatrix = new THREE.Matrix4(); // Thresholds const LOD_NEAR = 15; // Full mesh within 15 units const LOD_FAR = 40; // Billboard beyond 40 units const LOD_CULL = 80; // Don't render beyond 80 units const SPRITE_SIZE = 1.2; function init(sceneRef, cameraRef) { _scene = sceneRef; _camera = cameraRef; // Create shared sprite material const canvas = document.createElement('canvas'); canvas.width = 64; canvas.height = 64; const ctx = canvas.getContext('2d'); // Simple avatar indicator: colored circle ctx.fillStyle = '#00ffcc'; ctx.beginPath(); ctx.arc(32, 32, 20, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#0a0f1a'; ctx.beginPath(); ctx.arc(32, 28, 8, 0, Math.PI * 2); // head ctx.fill(); const texture = new THREE.CanvasTexture(canvas); _spriteMaterial = new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: true, sizeAttenuation: true, }); console.log('[LODSystem] Initialized'); } function registerAvatar(avatarMesh, userId, color) { // Create billboard sprite for this avatar const spriteMat = _spriteMaterial.clone(); if (color) { // Tint sprite to match avatar color const canvas = document.createElement('canvas'); canvas.width = 64; canvas.height = 64; const ctx = canvas.getContext('2d'); ctx.fillStyle = color; ctx.beginPath(); ctx.arc(32, 32, 20, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#0a0f1a'; ctx.beginPath(); ctx.arc(32, 28, 8, 0, Math.PI * 2); ctx.fill(); spriteMat.map = new THREE.CanvasTexture(canvas); spriteMat.map.needsUpdate = true; } const sprite = new THREE.Sprite(spriteMat); sprite.scale.set(SPRITE_SIZE, SPRITE_SIZE, 1); sprite.visible = false; _scene.add(sprite); _registered.set(userId, { mesh: avatarMesh, sprite: sprite, distance: Infinity, }); } function unregisterAvatar(userId) { const entry = _registered.get(userId); if (entry) { _scene.remove(entry.sprite); entry.sprite.material.dispose(); _registered.delete(userId); } } function setSpriteColor(userId, color) { const entry = _registered.get(userId); if (!entry) return; const canvas = document.createElement('canvas'); canvas.width = 64; canvas.height = 64; const ctx = canvas.getContext('2d'); ctx.fillStyle = color; ctx.beginPath(); ctx.arc(32, 32, 20, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#0a0f1a'; ctx.beginPath(); ctx.arc(32, 28, 8, 0, Math.PI * 2); ctx.fill(); entry.sprite.material.map = new THREE.CanvasTexture(canvas); entry.sprite.material.map.needsUpdate = true; } function update(playerPos) { if (!_camera) return; // Update frustum for culling _projScreenMatrix.multiplyMatrices( _camera.projectionMatrix, _camera.matrixWorldInverse ); _frustum.setFromProjectionMatrix(_projScreenMatrix); _registered.forEach((entry, userId) => { if (!entry.mesh) return; const meshPos = entry.mesh.position; const distance = playerPos.distanceTo(meshPos); entry.distance = distance; // Beyond cull distance: hide everything if (distance > LOD_CULL) { entry.mesh.visible = false; entry.sprite.visible = false; return; } // Check if in camera frustum const inFrustum = _frustum.containsPoint(meshPos); if (!inFrustum) { entry.mesh.visible = false; entry.sprite.visible = false; return; } // LOD switching if (distance <= LOD_NEAR) { // Near: full mesh entry.mesh.visible = true; entry.sprite.visible = false; } else if (distance <= LOD_FAR) { // Mid: mesh with reduced detail (keep mesh visible) entry.mesh.visible = true; entry.sprite.visible = false; } else { // Far: billboard sprite entry.mesh.visible = false; entry.sprite.visible = true; entry.sprite.position.copy(meshPos); entry.sprite.position.y += 1.2; // above avatar center } }); } function getStats() { let meshCount = 0; let spriteCount = 0; let culledCount = 0; _registered.forEach(entry => { if (entry.mesh.visible) meshCount++; else if (entry.sprite.visible) spriteCount++; else culledCount++; }); return { total: _registered.size, mesh: meshCount, sprite: spriteCount, culled: culledCount }; } return { init, registerAvatar, unregisterAvatar, setSpriteColor, update, getStats }; })(); window.LODSystem = LODSystem;