feat: add performance auto-detection and quality presets
Some checks failed
CI / validate (pull_request) Has been cancelled

- detectPerformanceTier() measures real frame time over 60 frames using
  requestAnimationFrame during scene init
- If avg FPS < 30 → LOW quality: bloom disabled, particles reduced from
  3000 → 800, core material simplified to wireframe MeshLambertMaterial
- Quality indicator badge added to HUD showing tier + detected FPS
- Full Three.js scene: stars, nexus core (icosahedron), particle cloud,
  floor grid, orbit controls, UnrealBloom post-processing
- CSS quality states: quality-high (green dot), quality-low (orange dot),
  quality-detecting (muted)

Fixes #94
This commit is contained in:
Alexander Whitestone
2026-03-23 23:57:36 -04:00
parent 554a4a030e
commit 634092f60e
3 changed files with 731 additions and 38 deletions

463
app.js
View File

@@ -1,27 +1,450 @@
// ... existing code ...
// === WEBSOCKET CLIENT ===
// === THE NEXUS — app.js ===
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { wsClient } from './ws-client.js';
// Initialize WebSocket client
wsClient.connect();
// === COLOR PALETTE ===
const NEXUS = {
colors: {
primary: 0x00ffff,
secondary: 0xff00ff,
accent: 0xffff00,
bg: 0x020408,
starWhite: 0xc0e8ff,
coreGlow: 0x00ffff,
nebula1: 0x1a0040,
nebula2: 0x001a40,
particle: 0x88ccff,
}
};
// Handle WebSocket events
window.addEventListener('player-joined', (event) => {
console.log('Player joined:', event.detail);
});
// === QUALITY PRESETS ===
const QUALITY_HIGH = 'high';
const QUALITY_LOW = 'low';
let currentQuality = null;
window.addEventListener('player-left', (event) => {
console.log('Player left:', event.detail);
});
// Scene globals
let scene, camera, renderer, controls;
let composer, bloomPass;
let nexusCore, particles, particlePositions;
const PARTICLE_COUNT_HIGH = 3000;
const PARTICLE_COUNT_LOW = 800;
window.addEventListener('chat-message', (event) => {
console.log('Chat message:', event.detail);
});
// === PERFORMANCE AUTO-DETECTION ===
// Measures actual frame time over N frames, returns average FPS.
function measureFPS(frameCount) {
return new Promise((resolve) => {
const times = [];
let prev = performance.now();
// Clean up on page unload
window.addEventListener('beforeunload', () => {
wsClient.disconnect();
});
function tick() {
const now = performance.now();
times.push(now - prev);
prev = now;
// ... existing code ...
if (times.length < frameCount) {
requestAnimationFrame(tick);
} else {
const avgMs = times.reduce((a, b) => a + b, 0) / times.length;
resolve(1000 / avgMs);
}
}
requestAnimationFrame(tick);
});
}
async function detectPerformanceTier() {
setLoadingStatus('Measuring performance…');
setLoadingProgress(50);
const fps = await measureFPS(60);
console.log(`[Nexus] Performance: ${fps.toFixed(1)} FPS avg over 60 frames`);
const tier = fps >= 30 ? QUALITY_HIGH : QUALITY_LOW;
applyQualityPreset(tier, fps);
return tier;
}
// === QUALITY APPLICATION ===
function applyQualityPreset(tier, fps) {
currentQuality = tier;
if (tier === QUALITY_LOW) {
// Disable bloom
if (composer && bloomPass) {
bloomPass.enabled = false;
}
// Reduce particle count
if (particles) {
rebuildParticles(PARTICLE_COUNT_LOW);
}
// Simplify core material
if (nexusCore) {
nexusCore.material.dispose();
nexusCore.material = new THREE.MeshLambertMaterial({
color: NEXUS.colors.primary,
wireframe: true,
});
}
console.log('[Nexus] Quality: LOW — bloom off, particles reduced, materials simplified');
} else {
// Ensure bloom is enabled (default)
if (composer && bloomPass) {
bloomPass.enabled = true;
}
console.log('[Nexus] Quality: HIGH — full rendering enabled');
}
updateQualityIndicator(tier, fps);
}
function updateQualityIndicator(tier, fps) {
const el = document.getElementById('quality-indicator');
const label = document.getElementById('quality-label');
if (!el || !label) return;
el.classList.remove('quality-high', 'quality-low', 'quality-detecting');
el.classList.add(`quality-${tier}`);
label.textContent = `${tier.toUpperCase()} · ${Math.round(fps)} FPS`;
}
// === SCENE SETUP ===
function initScene() {
scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(NEXUS.colors.bg, 0.018);
scene.background = new THREE.Color(NEXUS.colors.bg);
camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 600);
camera.position.set(0, 2, 12);
renderer = new THREE.WebGLRenderer({
canvas: document.getElementById('canvas'),
antialias: true,
powerPreference: 'high-performance',
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.06;
controls.minDistance = 3;
controls.maxDistance = 80;
controls.target.set(0, 0, 0);
// Post-processing
composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
bloomPass = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
1.2, // strength
0.5, // radius
0.75 // threshold
);
composer.addPass(bloomPass);
window.addEventListener('resize', onResize);
}
function onResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
composer.setSize(window.innerWidth, window.innerHeight);
}
// === LIGHTING ===
function buildLighting() {
scene.add(new THREE.AmbientLight(0x112233, 0.8));
const sun = new THREE.DirectionalLight(0xffffff, 1.2);
sun.position.set(10, 20, 10);
scene.add(sun);
const rimLight = new THREE.PointLight(NEXUS.colors.primary, 2, 30);
rimLight.position.set(-6, 4, -6);
scene.add(rimLight);
const fill = new THREE.PointLight(NEXUS.colors.secondary, 1, 20);
fill.position.set(6, -2, 6);
scene.add(fill);
}
// === STAR FIELD ===
function buildStars() {
const count = 4000;
const geo = new THREE.BufferGeometry();
const pos = new Float32Array(count * 3);
for (let i = 0; i < count * 3; i++) {
pos[i] = (Math.random() - 0.5) * 500;
}
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
const mat = new THREE.PointsMaterial({
color: NEXUS.colors.starWhite,
size: 0.35,
sizeAttenuation: true,
transparent: true,
opacity: 0.85,
});
scene.add(new THREE.Points(geo, mat));
}
// === NEXUS CORE ===
function buildNexusCore() {
const geo = new THREE.IcosahedronGeometry(1.2, 1);
const mat = new THREE.MeshStandardMaterial({
color: NEXUS.colors.primary,
emissive: NEXUS.colors.primary,
emissiveIntensity: 0.4,
metalness: 0.8,
roughness: 0.15,
});
nexusCore = new THREE.Mesh(geo, mat);
nexusCore.position.set(0, 0.5, 0);
scene.add(nexusCore);
// Pedestal
const pedGeo = new THREE.CylinderGeometry(0.3, 0.5, 0.5, 8);
const pedMat = new THREE.MeshStandardMaterial({ color: 0x224466, metalness: 0.6, roughness: 0.4 });
const pedestal = new THREE.Mesh(pedGeo, pedMat);
pedestal.position.set(0, -0.85, 0);
scene.add(pedestal);
// Glow ring
const ringGeo = new THREE.TorusGeometry(1.8, 0.04, 8, 64);
const ringMat = new THREE.MeshBasicMaterial({ color: NEXUS.colors.primary });
const ring = new THREE.Mesh(ringGeo, ringMat);
ring.rotation.x = Math.PI / 2;
ring.position.y = 0.5;
scene.add(ring);
}
// === PARTICLE SYSTEM ===
function buildParticles(count) {
const geo = new THREE.BufferGeometry();
particlePositions = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
const r = 3 + Math.random() * 14;
particlePositions[i * 3] = r * Math.sin(phi) * Math.cos(theta);
particlePositions[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
particlePositions[i * 3 + 2] = r * Math.cos(phi);
}
geo.setAttribute('position', new THREE.BufferAttribute(particlePositions, 3));
const mat = new THREE.PointsMaterial({
color: NEXUS.colors.particle,
size: 0.12,
sizeAttenuation: true,
transparent: true,
opacity: 0.7,
});
if (particles) {
scene.remove(particles);
particles.geometry.dispose();
particles.material.dispose();
}
particles = new THREE.Points(geo, mat);
scene.add(particles);
}
function rebuildParticles(count) {
buildParticles(count);
}
// === FLOOR GRID ===
function buildFloor() {
const helper = new THREE.GridHelper(40, 20, 0x0a2030, 0x071520);
helper.position.y = -1.2;
scene.add(helper);
const floorGeo = new THREE.PlaneGeometry(40, 40);
const floorMat = new THREE.MeshStandardMaterial({
color: 0x030d18,
roughness: 0.9,
metalness: 0.1,
transparent: true,
opacity: 0.85,
});
const floor = new THREE.Mesh(floorGeo, floorMat);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -1.2;
scene.add(floor);
}
// === ANIMATION LOOP ===
let clock;
function startLoop() {
clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const t = clock.getElapsedTime();
// Rotate core
if (nexusCore) {
nexusCore.rotation.y = t * 0.4;
nexusCore.rotation.x = t * 0.15;
}
// Drift particles
if (particles) {
particles.rotation.y = t * 0.02;
particles.rotation.x = t * 0.005;
}
controls.update();
composer.render();
}
animate();
}
// === CHAT ===
function initChat() {
const toggle = document.getElementById('chat-toggle');
const panel = document.getElementById('chat-panel');
const close = document.getElementById('chat-close');
const input = document.getElementById('chat-input');
const send = document.getElementById('chat-send');
const msgs = document.getElementById('chat-messages');
function openPanel() { panel.classList.add('open'); toggle.style.display = 'none'; }
function closePanel() { panel.classList.remove('open'); toggle.style.display = ''; }
toggle.addEventListener('click', openPanel);
close.addEventListener('click', closePanel);
function sendMsg() {
const text = input.value.trim();
if (!text) return;
appendMsg('You', text, '#aaddff');
wsClient.send({ type: 'chat-message', text });
input.value = '';
}
send.addEventListener('click', sendMsg);
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') sendMsg(); });
function appendMsg(from, text, color) {
const p = document.createElement('p');
p.innerHTML = `<span style="color:${color}">[${from}]</span> ${text}`;
msgs.appendChild(p);
msgs.scrollTop = msgs.scrollHeight;
}
window.addEventListener('chat-message', (e) => {
appendMsg('Timmy', e.detail.text || e.detail.message || '…', '#00ffcc');
});
}
// === AUDIO ===
function initAudio() {
const btn = document.getElementById('audio-toggle');
const audio = document.getElementById('ambient-sound');
let muted = true;
btn.addEventListener('click', () => {
muted = !muted;
if (muted) {
audio.pause();
btn.textContent = '🔇';
btn.classList.add('muted');
} else {
audio.play().catch(() => {});
btn.textContent = '🔊';
btn.classList.remove('muted');
}
});
}
// === WEBSOCKET ===
function initWebSocket() {
wsClient.connect();
window.addEventListener('player-joined', (e) => {
console.log('[Nexus] Player joined:', e.detail);
});
window.addEventListener('player-left', (e) => {
console.log('[Nexus] Player left:', e.detail);
});
window.addEventListener('beforeunload', () => wsClient.disconnect());
}
// === LOADING HELPERS ===
function setLoadingProgress(pct) {
const bar = document.getElementById('loading-progress');
if (bar) bar.style.width = `${pct}%`;
}
function setLoadingStatus(msg) {
const el = document.getElementById('loading-status');
if (el) el.textContent = msg;
}
function hideLoading() {
const el = document.getElementById('loading-screen');
if (el) {
el.classList.add('hidden');
setTimeout(() => el.remove(), 900);
}
}
// === BOOT ===
async function boot() {
// Set quality indicator to detecting state
const qEl = document.getElementById('quality-indicator');
if (qEl) qEl.classList.add('quality-detecting');
setLoadingProgress(10);
setLoadingStatus('Building scene…');
initScene();
buildLighting();
buildStars();
buildNexusCore();
buildParticles(PARTICLE_COUNT_HIGH);
buildFloor();
setLoadingProgress(30);
setLoadingStatus('Starting render loop…');
// Start rendering so the GPU warms up before measurement
startLoop();
setLoadingProgress(40);
// Measure real FPS over 60 frames (blocks ~1 second at 60 FPS)
await detectPerformanceTier();
setLoadingProgress(90);
setLoadingStatus('Ready.');
initChat();
initAudio();
initWebSocket();
setLoadingProgress(100);
setTimeout(hideLoading, 300);
}
boot();

View File

@@ -14,18 +14,64 @@
<meta name="twitter:description" content="A sovereign 3D world">
<meta name="twitter:image" content="https://example.com/og-image.png">
<link rel="manifest" href="/manifest.json">
<link rel="stylesheet" href="style.css">
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.183.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.183.0/examples/jsm/"
}
}
</script>
</head>
<body>
<!-- ... existing content ... -->
<canvas id="canvas"></canvas>
<!-- Top Right: Audio Toggle -->
<div id="audio-control" class="hud-controls" style="position: absolute; top: 8px; right: 8px;">
<button id="audio-toggle" class="chat-toggle-btn" aria-label="Toggle ambient sound" style="background-color: var(--color-primary); color: var(--color-bg); padding: 4px 8px; border-radius: 4px; font-size: 12px; cursor: pointer;">
🔊
</button>
<audio id="ambient-sound" src="ambient.mp3" loop></audio>
<!-- Loading Screen -->
<div id="loading-screen">
<div class="loading-inner">
<div class="loading-title">◈ THE NEXUS</div>
<div class="loading-bar"><div id="loading-progress"></div></div>
<div id="loading-status">Initializing...</div>
</div>
</div>
<!-- ... existing content ... -->
<!-- HUD -->
<div id="hud">
<!-- Top-left: Nexus label -->
<div id="hud-title">◈ NEXUS</div>
<!-- Top-right: Quality indicator + audio toggle -->
<div id="hud-top-right">
<div id="quality-indicator" class="hud-badge" title="Rendering quality tier">
<span id="quality-label">DETECTING...</span>
</div>
<div id="audio-control">
<button id="audio-toggle" class="hud-btn" aria-label="Toggle ambient sound">🔊</button>
<audio id="ambient-sound" src="ambient.mp3" loop></audio>
</div>
</div>
<!-- Bottom-left: navigation hint -->
<div id="hud-hint">WASD + drag to explore</div>
</div>
<!-- Chat Panel (Timmy Terminal) -->
<div id="chat-panel">
<div id="chat-header">
<span>TIMMY TERMINAL</span>
<button id="chat-close" class="hud-btn" aria-label="Close chat"></button>
</div>
<div id="chat-messages"></div>
<div id="chat-input-row">
<input id="chat-input" type="text" placeholder="Message Timmy..." autocomplete="off">
<button id="chat-send" class="hud-btn">SEND</button>
</div>
</div>
<!-- Chat toggle -->
<button id="chat-toggle" class="hud-btn" aria-label="Toggle chat">💬</button>
<script type="module" src="app.js"></script>
</body>
</html>

244
style.css
View File

@@ -1,18 +1,242 @@
/* === NEXUS DESIGN SYSTEM === */
:root {
--color-bg: #020408;
--color-primary: #00ffff;
--color-secondary: #ff00ff;
--color-accent: #ffff00;
--color-text: #c0e8ff;
--color-text-muted: #4a7a9b;
--color-panel: rgba(0, 20, 40, 0.85);
--color-border: rgba(0, 255, 255, 0.25);
--font-body: 'Courier New', Courier, monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-body);
overflow: hidden;
width: 100vw;
height: 100vh;
}
#canvas {
display: block;
width: 100vw;
height: 100vh;
position: fixed;
top: 0; left: 0;
}
/* === LOADING SCREEN === */
#loading-screen {
position: fixed;
inset: 0;
background: var(--color-bg);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
transition: opacity 0.8s ease;
}
#loading-screen.hidden { opacity: 0; pointer-events: none; }
.loading-inner { text-align: center; }
.loading-title {
font-size: 2.5rem;
color: var(--color-primary);
letter-spacing: 0.3em;
margin-bottom: 1.5rem;
text-shadow: 0 0 20px var(--color-primary);
}
.loading-bar {
width: 240px;
height: 4px;
background: var(--color-border);
border-radius: 2px;
margin: 0 auto 0.75rem;
overflow: hidden;
}
#loading-progress {
height: 100%;
width: 0;
background: var(--color-primary);
border-radius: 2px;
transition: width 0.3s ease;
box-shadow: 0 0 8px var(--color-primary);
}
#loading-status {
font-size: 0.7rem;
color: var(--color-text-muted);
letter-spacing: 0.15em;
}
/* === HUD === */
#hud {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 10;
}
#hud-title {
position: absolute;
top: 12px; left: 16px;
font-size: 1rem;
letter-spacing: 0.3em;
color: var(--color-primary);
text-shadow: 0 0 12px var(--color-primary);
}
#hud-top-right {
position: absolute;
top: 8px; right: 8px;
display: flex;
align-items: center;
gap: 8px;
pointer-events: auto;
}
/* Quality Indicator */
.hud-badge {
background: var(--color-panel);
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 4px 10px;
font-size: 0.65rem;
letter-spacing: 0.12em;
display: flex;
align-items: center;
gap: 6px;
backdrop-filter: blur(4px);
}
.hud-badge::before {
content: '';
display: inline-block;
width: 6px; height: 6px;
border-radius: 50%;
background: var(--quality-dot, var(--color-text-muted));
box-shadow: 0 0 4px var(--quality-dot, transparent);
}
#quality-indicator.quality-high {
--quality-dot: #00ff88;
border-color: rgba(0, 255, 136, 0.4);
color: #00ff88;
}
#quality-indicator.quality-low {
--quality-dot: #ff8800;
border-color: rgba(255, 136, 0, 0.4);
color: #ff8800;
}
#quality-indicator.quality-detecting {
--quality-dot: var(--color-text-muted);
color: var(--color-text-muted);
}
#hud-hint {
position: absolute;
bottom: 16px; left: 50%;
transform: translateX(-50%);
font-size: 0.65rem;
color: var(--color-text-muted);
letter-spacing: 0.1em;
pointer-events: none;
}
/* === BUTTONS === */
.hud-btn {
background: var(--color-panel);
border: 1px solid var(--color-border);
color: var(--color-text);
font-family: var(--font-body);
font-size: 0.75rem;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
backdrop-filter: blur(4px);
transition: border-color 0.2s, color 0.2s;
pointer-events: auto;
}
.hud-btn:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
/* === CHAT PANEL === */
#chat-panel {
position: fixed;
bottom: 16px; right: 16px;
width: 320px;
max-height: 380px;
background: var(--color-panel);
border: 1px solid var(--color-border);
border-radius: 8px;
display: flex;
flex-direction: column;
backdrop-filter: blur(8px);
z-index: 20;
transform: translateY(calc(100% + 16px));
transition: transform 0.3s ease;
}
#chat-panel.open { transform: translateY(0); }
#chat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
border-bottom: 1px solid var(--color-border);
font-size: 0.7rem;
letter-spacing: 0.15em;
color: var(--color-primary);
}
#chat-messages {
flex: 1;
overflow-y: auto;
padding: 8px 12px;
font-size: 0.75rem;
line-height: 1.5;
min-height: 180px;
}
#chat-input-row {
display: flex;
gap: 6px;
padding: 8px 12px;
border-top: 1px solid var(--color-border);
}
#chat-input {
flex: 1;
background: rgba(0, 0, 0, 0.4);
border: 1px solid var(--color-border);
color: var(--color-text);
font-family: var(--font-body);
font-size: 0.75rem;
padding: 4px 8px;
border-radius: 4px;
outline: none;
}
#chat-input:focus { border-color: var(--color-primary); }
#chat-toggle {
position: fixed;
bottom: 16px; right: 16px;
z-index: 15;
font-size: 1.1rem;
padding: 8px 12px;
}
#chat-panel.open ~ #chat-toggle { opacity: 0; pointer-events: none; }
/* === AUDIO TOGGLE === */
#audio-toggle {
font-size: 14px;
background-color: var(--color-primary-primary);
color: var(--color-bg);
background-color: var(--color-panel);
color: var(--color-text);
padding: 4px 8px;
border-radius: 4px;
font-family: var(--font-body);
transition: background-color 0.3s ease;
}
#audio-toggle:hover {
background-color: var(--color-secondary);
}
#audio-toggle.muted {
background-color: var(--color-text-muted);
}
#audio-toggle:hover { background-color: rgba(0, 255, 255, 0.1); }
#audio-toggle.muted { color: var(--color-text-muted); }