- 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
425 lines
11 KiB
JavaScript
425 lines
11 KiB
JavaScript
/**
|
|
* Enhanced LOD (Level of Detail) System for The Nexus
|
|
*
|
|
* Optimizes rendering for local hardware sovereignty:
|
|
* - THREE.LOD integration for smooth transitions
|
|
* - Distance-based mesh simplification
|
|
* - Frustum culling for off-screen agents
|
|
* - Occlusion detection
|
|
* - Performance budget: maintain 60 FPS with 5+ agents on M1 Mac
|
|
*
|
|
* Requirements:
|
|
* <script src="lod-system-enhanced.js"></script>
|
|
* LODSystem.init(scene, camera, renderer);
|
|
* LODSystem.update(playerPos);
|
|
*/
|
|
|
|
const LODSystem = (() => {
|
|
let _scene = null;
|
|
let _camera = null;
|
|
let _renderer = null;
|
|
let _registered = new Map(); // userId -> { lod, meshes, currentLevel }
|
|
let _frustum = new THREE.Frustum();
|
|
let _projScreenMatrix = new THREE.Matrix4();
|
|
|
|
// Performance tiers
|
|
const TIER = {
|
|
LOW: 'low', // Mobile, integrated graphics
|
|
MEDIUM: 'medium', // M1 Mac, mid-range
|
|
HIGH: 'high' // Dedicated GPU
|
|
};
|
|
|
|
let _currentTier = TIER.MEDIUM;
|
|
|
|
// LOD thresholds by tier
|
|
const LOD_THRESHOLDS = {
|
|
[TIER.LOW]: {
|
|
near: 10,
|
|
mid: 25,
|
|
far: 50,
|
|
cull: 80
|
|
},
|
|
[TIER.MEDIUM]: {
|
|
near: 15,
|
|
mid: 40,
|
|
far: 80,
|
|
cull: 120
|
|
},
|
|
[TIER.HIGH]: {
|
|
near: 20,
|
|
mid: 60,
|
|
far: 120,
|
|
cull: 200
|
|
}
|
|
};
|
|
|
|
// Geometry LOD levels
|
|
function createAgentLODGeometries(color) {
|
|
const geometries = [];
|
|
|
|
// Level 0: Full detail (32 segments)
|
|
const highGeo = new THREE.SphereGeometry(0.4, 32, 32);
|
|
geometries.push(highGeo);
|
|
|
|
// Level 1: Medium detail (16 segments)
|
|
const midGeo = new THREE.SphereGeometry(0.4, 16, 16);
|
|
geometries.push(midGeo);
|
|
|
|
// Level 2: Low detail (8 segments)
|
|
const lowGeo = new THREE.SphereGeometry(0.4, 8, 8);
|
|
geometries.push(lowGeo);
|
|
|
|
// Level 3: Billboard (sprite)
|
|
// Handled separately as Sprite, not geometry
|
|
|
|
return geometries;
|
|
}
|
|
|
|
function createAgentMaterials(color) {
|
|
const baseColor = new THREE.Color(color);
|
|
|
|
return [
|
|
// Level 0: Full PBR material
|
|
new THREE.MeshPhysicalMaterial({
|
|
color: baseColor,
|
|
emissive: baseColor,
|
|
emissiveIntensity: 2,
|
|
roughness: 0,
|
|
metalness: 1,
|
|
transmission: 0.8,
|
|
thickness: 0.5,
|
|
clearcoat: 1,
|
|
clearcoatRoughness: 0.1
|
|
}),
|
|
// Level 1: Simplified PBR
|
|
new THREE.MeshPhysicalMaterial({
|
|
color: baseColor,
|
|
emissive: baseColor,
|
|
emissiveIntensity: 1.5,
|
|
roughness: 0.1,
|
|
metalness: 0.8,
|
|
transmission: 0.5
|
|
}),
|
|
// Level 2: Basic material
|
|
new THREE.MeshBasicMaterial({
|
|
color: baseColor,
|
|
transparent: true,
|
|
opacity: 0.9
|
|
})
|
|
];
|
|
}
|
|
|
|
function createBillboardSprite(color) {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = 64;
|
|
canvas.height = 64;
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
// Gradient orb
|
|
const gradient = ctx.createRadialGradient(32, 32, 0, 32, 32, 28);
|
|
const hexColor = '#' + new THREE.Color(color).getHexString();
|
|
gradient.addColorStop(0, hexColor);
|
|
gradient.addColorStop(0.7, hexColor + 'aa');
|
|
gradient.addColorStop(1, 'transparent');
|
|
|
|
ctx.fillStyle = gradient;
|
|
ctx.beginPath();
|
|
ctx.arc(32, 32, 28, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
// Inner glow
|
|
ctx.fillStyle = hexColor;
|
|
ctx.beginPath();
|
|
ctx.arc(32, 32, 12, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
const texture = new THREE.CanvasTexture(canvas);
|
|
texture.minFilter = THREE.LinearFilter;
|
|
texture.magFilter = THREE.LinearFilter;
|
|
texture.generateMipmaps = false; // Save memory for sprites
|
|
|
|
const material = new THREE.SpriteMaterial({
|
|
map: texture,
|
|
transparent: true,
|
|
depthTest: true,
|
|
sizeAttenuation: true,
|
|
blending: THREE.AdditiveBlending
|
|
});
|
|
|
|
const sprite = new THREE.Sprite(material);
|
|
sprite.scale.set(1.2, 1.2, 1);
|
|
|
|
return sprite;
|
|
}
|
|
|
|
function detectPerformanceTier() {
|
|
const gl = document.createElement('canvas').getContext('webgl');
|
|
if (!gl) return TIER.LOW;
|
|
|
|
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
|
|
if (!debugInfo) return TIER.MEDIUM;
|
|
|
|
const renderer = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL);
|
|
|
|
// Detect Apple Silicon
|
|
if (renderer.includes('Apple')) {
|
|
return renderer.includes('M3') || renderer.includes('M2') || renderer.includes('M1 Pro')
|
|
? TIER.HIGH : TIER.MEDIUM;
|
|
}
|
|
|
|
// Detect integrated graphics
|
|
if (renderer.includes('Intel') && !renderer.includes('Arc')) {
|
|
return TIER.LOW;
|
|
}
|
|
|
|
// Mobile detection
|
|
if (/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)) {
|
|
return TIER.LOW;
|
|
}
|
|
|
|
return TIER.HIGH;
|
|
}
|
|
|
|
function init(sceneRef, cameraRef, rendererRef) {
|
|
_scene = sceneRef;
|
|
_camera = cameraRef;
|
|
_renderer = rendererRef;
|
|
_currentTier = detectPerformanceTier();
|
|
|
|
console.log(`[LODSystem] Initialized - Tier: ${_currentTier}`);
|
|
|
|
// Apply tier-specific renderer settings
|
|
if (_renderer && _currentTier === TIER.LOW) {
|
|
_renderer.setPixelRatio(1);
|
|
_renderer.shadowMap.enabled = false;
|
|
} else if (_renderer && _currentTier === TIER.MEDIUM) {
|
|
_renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
|
|
_renderer.shadowMap.type = THREE.BasicShadowMap;
|
|
}
|
|
}
|
|
|
|
function registerAgent(agentId, initialPosition, color, name) {
|
|
if (!_scene) return null;
|
|
|
|
const lod = new THREE.LOD();
|
|
lod.position.copy(initialPosition);
|
|
lod.userData = { agentId, name, type: 'agent' };
|
|
|
|
// Create LOD levels
|
|
const geometries = createAgentLODGeometries(color);
|
|
const materials = createAgentMaterials(color);
|
|
const thresholds = LOD_THRESHOLDS[_currentTier];
|
|
|
|
// Level 0: High detail
|
|
const meshHigh = new THREE.Mesh(geometries[0], materials[0]);
|
|
meshHigh.castShadow = _currentTier !== TIER.LOW;
|
|
meshHigh.receiveShadow = _currentTier !== TIER.LOW;
|
|
lod.addLevel(meshHigh, 0);
|
|
|
|
// Level 1: Medium detail
|
|
const meshMid = new THREE.Mesh(geometries[1], materials[1]);
|
|
lod.addLevel(meshMid, thresholds.near);
|
|
|
|
// Level 2: Low detail
|
|
const meshLow = new THREE.Mesh(geometries[2], materials[2]);
|
|
lod.addLevel(meshLow, thresholds.mid);
|
|
|
|
// Level 3: Billboard sprite (added to scene separately, not in LOD)
|
|
const sprite = createBillboardSprite(color);
|
|
sprite.position.copy(initialPosition);
|
|
sprite.position.y += 0.4;
|
|
sprite.visible = false;
|
|
_scene.add(sprite);
|
|
|
|
// Label
|
|
const labelCanvas = document.createElement('canvas');
|
|
labelCanvas.width = 256;
|
|
labelCanvas.height = 64;
|
|
const ctx = labelCanvas.getContext('2d');
|
|
ctx.font = 'bold 24px "Orbitron", sans-serif';
|
|
ctx.fillStyle = '#' + new THREE.Color(color).getHexString();
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText(name, 128, 40);
|
|
|
|
const labelTex = new THREE.CanvasTexture(labelCanvas);
|
|
labelTex.minFilter = THREE.LinearFilter;
|
|
labelTex.generateMipmaps = false;
|
|
|
|
const labelMat = new THREE.MeshBasicMaterial({
|
|
map: labelTex,
|
|
transparent: true,
|
|
side: THREE.DoubleSide
|
|
});
|
|
const labelMesh = new THREE.Mesh(new THREE.PlaneGeometry(2, 0.5), labelMat);
|
|
labelMesh.position.set(0, 0.8, 0);
|
|
labelMesh.visible = true;
|
|
lod.add(labelMesh);
|
|
|
|
_scene.add(lod);
|
|
|
|
_registered.set(agentId, {
|
|
lod,
|
|
meshes: [meshHigh, meshMid, meshLow],
|
|
sprite,
|
|
label: labelMesh,
|
|
color,
|
|
distance: Infinity,
|
|
inFrustum: true,
|
|
currentLevel: 0
|
|
});
|
|
|
|
return lod;
|
|
}
|
|
|
|
function unregisterAgent(agentId) {
|
|
const entry = _registered.get(agentId);
|
|
if (!entry) return;
|
|
|
|
_scene.remove(entry.lod);
|
|
_scene.remove(entry.sprite);
|
|
|
|
// Dispose geometries and materials
|
|
entry.meshes.forEach(mesh => {
|
|
mesh.geometry.dispose();
|
|
mesh.material.dispose();
|
|
});
|
|
entry.sprite.material.map.dispose();
|
|
entry.sprite.material.dispose();
|
|
entry.label.material.map.dispose();
|
|
entry.label.material.dispose();
|
|
|
|
_registered.delete(agentId);
|
|
}
|
|
|
|
function update(playerPos) {
|
|
if (!_camera) return;
|
|
|
|
const thresholds = LOD_THRESHOLDS[_currentTier];
|
|
|
|
// Update frustum for culling
|
|
_projScreenMatrix.multiplyMatrices(
|
|
_camera.projectionMatrix,
|
|
_camera.matrixWorldInverse
|
|
);
|
|
_frustum.setFromProjectionMatrix(_projScreenMatrix);
|
|
|
|
_registered.forEach((entry, agentId) => {
|
|
const lodPos = entry.lod.position;
|
|
const distance = playerPos.distanceTo(lodPos);
|
|
entry.distance = distance;
|
|
|
|
// Frustum culling
|
|
const inFrustum = _frustum.containsPoint(lodPos);
|
|
entry.inFrustum = inFrustum;
|
|
|
|
// Distance culling
|
|
if (distance > thresholds.cull || !inFrustum) {
|
|
entry.lod.visible = false;
|
|
entry.sprite.visible = false;
|
|
return;
|
|
}
|
|
|
|
entry.lod.visible = true;
|
|
|
|
// THREE.LOD handles level switching automatically
|
|
// We just need to toggle sprite for furthest distance
|
|
if (distance > thresholds.far) {
|
|
entry.lod.visible = false;
|
|
entry.sprite.visible = true;
|
|
entry.sprite.position.copy(lodPos);
|
|
entry.sprite.position.y += 0.4;
|
|
entry.currentLevel = 3;
|
|
} else {
|
|
entry.sprite.visible = false;
|
|
entry.currentLevel = entry.lod.getCurrentLevel();
|
|
}
|
|
|
|
// Make label always face camera
|
|
entry.label.lookAt(_camera.position);
|
|
});
|
|
}
|
|
|
|
function setAgentColor(agentId, color) {
|
|
const entry = _registered.get(agentId);
|
|
if (!entry) return;
|
|
|
|
entry.color = color;
|
|
const materials = createAgentMaterials(color);
|
|
|
|
entry.meshes.forEach((mesh, i) => {
|
|
mesh.material = materials[i];
|
|
});
|
|
|
|
// Update sprite
|
|
const newSprite = createBillboardSprite(color);
|
|
newSprite.position.copy(entry.sprite.position);
|
|
newSprite.visible = entry.sprite.visible;
|
|
_scene.remove(entry.sprite);
|
|
entry.sprite.material.map.dispose();
|
|
entry.sprite.material.dispose();
|
|
entry.sprite = newSprite;
|
|
_scene.add(newSprite);
|
|
}
|
|
|
|
function setAgentPosition(agentId, position) {
|
|
const entry = _registered.get(agentId);
|
|
if (!entry) return;
|
|
entry.lod.position.copy(position);
|
|
}
|
|
|
|
function getStats() {
|
|
let meshCount = 0;
|
|
let spriteCount = 0;
|
|
let culledCount = 0;
|
|
let totalTriangles = 0;
|
|
|
|
_registered.forEach(entry => {
|
|
if (entry.sprite.visible) {
|
|
spriteCount++;
|
|
} else if (entry.lod.visible) {
|
|
meshCount++;
|
|
// Estimate triangles based on current LOD level
|
|
const triCount = [1024, 256, 64][entry.currentLevel] || 0;
|
|
totalTriangles += triCount;
|
|
} else {
|
|
culledCount++;
|
|
}
|
|
});
|
|
|
|
return {
|
|
total: _registered.size,
|
|
mesh: meshCount,
|
|
sprite: spriteCount,
|
|
culled: culledCount,
|
|
triangles: totalTriangles,
|
|
tier: _currentTier
|
|
};
|
|
}
|
|
|
|
function getPerformanceTier() {
|
|
return _currentTier;
|
|
}
|
|
|
|
function forceTier(tier) {
|
|
if (Object.values(TIER).includes(tier)) {
|
|
_currentTier = tier;
|
|
console.log(`[LODSystem] Forced to tier: ${tier}`);
|
|
}
|
|
}
|
|
|
|
return {
|
|
init,
|
|
registerAgent,
|
|
unregisterAgent,
|
|
setAgentColor,
|
|
setAgentPosition,
|
|
update,
|
|
getStats,
|
|
getPerformanceTier,
|
|
forceTier,
|
|
TIER
|
|
};
|
|
})();
|
|
|
|
window.LODSystem = LODSystem;
|