Auditoría: HUD de Performance Metrics en Three.js
Fecha: 2026-09-18
Proyecto: three-js-prompt (script.js: 32 líneas)
Objetivo: Implementar HUD con métricas de draw calls y performance
1. Auditoría de Arquitectura HUD Ideal
1.1 Estructura recomendada
Para proyectos pequeños/medianos (como este), la mejor práctica es:
├── HUD como componente separado pero simple
│ ├── DOM independiente (no interfiere con canvas)
│ ├── Actualización en el animation loop
│ └── Estilos CSS aislados
Razonamiento:
- Separación de concerns: El HUD es visualización de debug, no lógica 3D
- Performance: DOM updates fuera del render path de Three.js
- Mantenibilidad: Fácil agregar/remover métricas sin tocar el loop principal
- Escalabilidad: Patrón que crece bien cuando agreguez más métricas
1.2 Inline vs. Componente
Para este proyecto: INLINE es correcto porque:
- Proyecto simple (~40 líneas total)
- Una única escena
- No hay reutilización del HUD
Si escalara a múltiples escenas o un panel complejo, extraerías a una clase:
class PerformanceHUD {
constructor(containerId) { ... }
update(renderer) { ... }
show() { ... }
hide() { ... }
}1.3 Patrones recomendables en Three.js
| Patrón | Cuándo | Ejemplo |
|---|---|---|
| Inline | < 100 LOC, una escena | Este proyecto |
| Clase wrapper | > 100 LOC, múltiples escenas | Motor reutilizable |
| UI Framework | Paneles complejos (React, Vue) | Dashboard profesional |
| Overlay canvas | Necesitas rendering 3D para HUD | Gráficos 3D dinámicos |
Para este proyecto: Usa DOM HTML + CSS + updateTextContent cada frame. Es 100% correcto.
2. Análisis Específico de renderer.info.render.calls
2.1 ¿Qué es exactamente?
renderer.info.render.calls // Número de veces que WebGL bindProgram() se llamóDefinición técnica:
- Cada vez que Three.js cambia el material/shader, incrementa
calls - Cada mesh renderizado con un programa/material distinto = +1 call
- Si renderizas 10 meshes con el MISMO material = 1 call total
- Si renderizas 2 meshes con 2 materiales distintos = 2 calls
Ejemplo visual:
// Escena actual (script.js):
// 1 plano verde (1 material)
// → calls = 1 siempre
// Escena con optimización:
const plane1 = new THREE.Mesh(geometry, material); // calls++
const plane2 = new THREE.Mesh(geometry, material); // NO calls++ (reutiliza material)
const cube = new THREE.Mesh(cubeGeom, otherMaterial); // calls++ (nuevo material)
// Total: 2 calls2.2 ¿Cómo se resetea cada frame?
Three.js resetea automáticamente renderer.info después de cada renderer.render():
// Dentro del loop de animación:
renderer.render(scene, camera); // Renderiza + internamente resetea info
console.log(renderer.info.render.calls); // Muestra calls del frame anteriorIMPORTANTE: Los valores que lees son del frame que acabó de renderizar.
Flujo correcto:
function animate() {
requestAnimationFrame(animate);
renderer.render(scene, camera);
// En este punto, info.render.calls contiene los calls del frame que acaba de renderizarse
console.log(renderer.info.render.calls); // ✓ Correcto
}2.3 Valores típicos esperados
| Escenario | Calls típicas | Optimización |
|---|---|---|
| 1 plano (este proyecto) | 1 | Óptimo |
| 10 meshes, 1 material | 1 | Óptimo |
| 10 meshes, 10 materiales | 10 | Malo → consolidar |
| 100 meshes, instanciados | 1 | Óptimo (InstancedMesh) |
| Cielo + 50 árboles + personaje | 3-5 | Normal |
Regla de oro: Menos calls = mejor. Típicamente:
- Bueno: 1-5 calls
- Aceptable: 5-20 calls
- Revisar: > 50 calls
2.4 Optimizaciones relacionadas
// Otras métricas útiles:
renderer.info.render.triangles // Vértices renderizados
renderer.info.render.points // Partículas, sprites, etc.
renderer.info.render.lines // Líneas
renderer.info.memory.geometries // Geometrías en memoria
renderer.info.memory.textures // Texturas en memoria3. Recomendaciones Concretas para Este Proyecto
3.1 Estado actual
- ✓ Scene simple (1 plano = 1 draw call)
- ✓ No hay optimizaciones urgentes
- ✗ Sin visibilidad de métricas
3.2 Objetivos del HUD
- Monitor en tiempo real: Calls, FPS, triangles
- Ubicación: Arriba-izquierda (esquina no invasiva)
- Actualización: Cada frame, sin lag
- Escalable: Fácil agregar más métricas
3.3 Decisiones de diseño
| Decisión | Opción | Elegida | Razón |
|---|---|---|---|
| Ubicación | Arriba-izquierda vs. Flotante | Arriba-izquierda | Estándar en game engines |
| Fuente | Monospace vs. Sans-serif | Monospace | Legibilidad números |
| Actualización | innerHTML vs. textContent | textContent | Mejor performance |
| Formato | Vertical vs. Horizontal | Vertical | Menos espacio, más legible |
| Fondo | Transparente vs. Opaco | Semi-opaco (rgba) | Legible siempre |
4. Plan de Implementación Paso a Paso
Paso 1: Crear Elemento HUD en HTML
Archivo: index.html
Cambio: Agregar después del <body> pero antes de <script src="script.js">
<div id="hud" style="
position: fixed;
top: 10px;
left: 10px;
font-family: 'Courier New', monospace;
font-size: 12px;
color: #00ff00;
background: rgba(0, 0, 0, 0.7);
padding: 8px 12px;
border-radius: 4px;
line-height: 1.6;
z-index: 100;
pointer-events: none;
">
</div>Por qué estos estilos:
position: fixed→ No se mueve con scroll/pantop: 10px; left: 10px→ Arriba-izquierda estándarpointer-events: none→ No bloquea clicks en canvasmonospace→ Números se alinean perfectorgba(0, 0, 0, 0.7)→ Visible sobre fondos claros y oscurosz-index: 100→ Por encima del canvas
Paso 2: Obtener referencias y crear objeto de métricas
Archivo: script.js (agregar después de crear renderer)
// Crear referencia al HUD
const hudElement = document.getElementById('hud');
// Objeto centralizado de métricas (preparado para escalabilidad)
const metrics = {
lastTime: performance.now(),
frameCount: 0,
fps: 0,
};Por qué un objeto metrics:
- Preparado para agregar más tarde:
memory,latency, etc. - Fácil de mockear/testear
- Código más limpio que variables globales sueltas
Paso 3: Actualizar métricas dentro del loop
Archivo: script.js (modificar función animate())
VERSIÓN SIMPLE (recomendada para empezar):
function animate() {
requestAnimationFrame(animate);
renderer.render(scene, camera);
// Actualizar FPS cada 100ms (4-5 veces por segundo)
const now = performance.now();
metrics.frameCount++;
if (now - metrics.lastTime >= 100) {
metrics.fps = Math.round((metrics.frameCount * 1000) / (now - metrics.lastTime));
metrics.lastTime = now;
metrics.frameCount = 0;
}
// Actualizar HUD
updateHUD();
}
function updateHUD() {
const calls = renderer.info.render.calls;
const triangles = renderer.info.render.triangles;
hudElement.textContent = `
FPS: ${metrics.fps}
Draw Calls: ${calls}
Triangles: ${triangles}
`.trim();
}Nota importante: Se usa textContent (no innerHTML) porque:
- ✓ Más rápido (no parsea HTML)
- ✓ No vulnerable a XSS
- ✓ Suficiente para texto plano
Paso 4: Formato visual mejorado
Si quieres mejor presentación (con saltos de línea adecuados):
function updateHUD() {
const calls = renderer.info.render.calls;
const triangles = renderer.info.render.triangles;
const memory = renderer.info.memory.geometries +
renderer.info.memory.textures;
hudElement.innerHTML = `
<div>FPS: ${metrics.fps}</div>
<div>Calls: ${calls}</div>
<div>Triangles: ${triangles}</div>
<div>Memory: ${memory} objects</div>
`;
}Vs. usar textContent con \n:
hudElement.textContent =
`FPS: ${metrics.fps}\n` +
`Calls: ${calls}\n` +
`Triangles: ${triangles}`;Diferencia de performance: < 1ms en ambos casos. Usa lo que veas más limpio.
Recomendación: Para este proyecto, innerHTML es más legible. Si el HUD fuera parte de un panel más complejo, usarías librerías (React, etc).
Paso 5: Ubicación en pantalla (ajustes opcionales)
/* Si quieres esquina inferior-derecha en lugar de arriba-izquierda: */
#hud {
bottom: 10px;
right: 10px;
top: auto;
left: auto;
}
/* Si quieres centrado arriba: */
#hud {
left: 50%;
transform: translateX(-50%);
}
/* Tema oscuro (por defecto, ya está bien): */
/* Tema claro alternativo: */
#hud {
background: rgba(255, 255, 255, 0.9);
color: #000;
}Paso 6: Actualización eficiente
Matriz de performance:
| Método | FPS Hit | Cuándo usar |
|---|---|---|
| Actualizar cada frame | Nulo (~0%) | Proyecto actual (simple) |
| Actualizar c/100ms | Nulo (~0%) | Proyecto actual (buena práctica) |
| Usar WebWorker | Mínimo | HUD muy complejo |
| Usar Canvas para HUD | Depende | Si necesitas gráficos (gauges, etc) |
Para este proyecto: Actualizar cada 100ms es lo correcto. No afecta FPS.
Paso 7: Testing / Verificación
// Test 1: Verificar que renderer.info existe
console.assert(renderer.info, "❌ renderer.info no existe");
console.assert(renderer.info.render, "❌ renderer.info.render no existe");
console.assert(renderer.info.render.calls !== undefined, "❌ calls no definido");
// Test 2: Verificar que HUD se actualiza
const originalUpdate = updateHUD;
let updateCount = 0;
updateHUD = function() {
updateCount++;
originalUpdate();
};
// Esperar 3 frames
setTimeout(() => {
console.log(`HUD actualizado ${updateCount} veces en ~16ms`);
console.assert(updateCount >= 3, "❌ HUD no se actualiza suficiente");
}, 50);
// Test 3: Valores sensatos
setTimeout(() => {
const calls = renderer.info.render.calls;
console.log(`Draw calls: ${calls}`);
console.assert(calls > 0, "❌ Calls debe ser > 0");
console.assert(calls <= 100, "⚠️ Calls muy altos, revisar optimización");
}, 200);Salida esperada en console:
HUD actualizado 4 veces en ~16ms
Draw calls: 1
✓ Todos los asserts pasan
5. Consideraciones de Escalabilidad
5.1 Arquitectura para crecer
Actual (7 líneas de código):
const metrics = { fps: 0, lastTime: 0, frameCount: 0 };
function updateHUD() { /* lógica */ }Escalada Nivel 1 (agregar 5 métricas más):
const metrics = {
fps: 0,
memory: 0,
drawTime: 0,
cameraPos: { x: 0, y: 0, z: 0 },
mousePos: { x: 0, y: 0 },
// ...más
};
function updateHUD() {
const info = {
fps: metrics.fps,
calls: renderer.info.render.calls,
triangles: renderer.info.render.triangles,
memory: getMemoryUsage(),
camera: `${metrics.cameraPos.x.toFixed(2)}, ...`
};
// Renderizar cada métrica
Object.entries(info).forEach(([key, value]) => {
hudElement.innerHTML += `<div>${key}: ${value}</div>`;
});
}Escalada Nivel 2 (Clase wrapper — cuando superes 150 LOC):
class PerformanceMonitor {
constructor(containerId) {
this.container = document.getElementById(containerId);
this.metrics = {};
this.enabled = true;
}
update(renderer, camera) {
if (!this.enabled) return;
this.metrics.calls = renderer.info.render.calls;
this.metrics.fps = this.calculateFPS();
this.metrics.memory = this.getMemoryUsage();
this.render();
}
calculateFPS() { /* ... */ }
getMemoryUsage() { /* ... */ }
render() { /* ... */ }
show() { this.container.style.display = 'block'; }
hide() { this.container.style.display = 'none'; }
}
// Uso:
const monitor = new PerformanceMonitor('hud');
// En animate loop:
monitor.update(renderer, camera);5.2 Agregar métricas sin refactor
Patrón recomendable — Función helpers:
// Helpers reutilizables
const HUDMetrics = {
getCalls: (renderer) => renderer.info.render.calls,
getTriangles: (renderer) => renderer.info.render.triangles,
getMemory: (renderer) => renderer.info.memory.geometries + renderer.info.memory.textures,
getFPS: (frameCount, deltaTime) => Math.round((frameCount * 1000) / deltaTime),
getCameraPos: (camera) => ({
x: camera.position.x.toFixed(2),
y: camera.position.y.toFixed(2),
z: camera.position.z.toFixed(2),
}),
};
// Uso limpio:
function updateHUD() {
const hud = {
fps: HUDMetrics.getFPS(metrics.frameCount, metrics.deltaTime),
calls: HUDMetrics.getCalls(renderer),
triangles: HUDMetrics.getTriangles(renderer),
camera: HUDMetrics.getCameraPos(camera),
};
// Renderizar
hudElement.innerHTML = Object.entries(hud)
.map(([k, v]) => `<div>${k}: ${JSON.stringify(v)}</div>`)
.join('');
}Ventajas:
- Métrica nueva = 1 línea en
HUDMetrics - No necesita refactor en
updateHUD() - Fácil testear cada métrica
- Reutilizable en múltiples escenas
5.3 Mantener código limpio
Anti-pattern (evitar):
// ❌ Spaguetti
function updateHUD() {
let html = `<div>${renderer.info.render.calls}</div>`;
html += `<div>${renderer.info.render.triangles}</div>`;
html += `<div>${performance.now()}</div>`;
// ... 20 líneas más
hudElement.innerHTML = html;
}Pattern (mantener limpio):
// ✓ Separación de concerns
const metricNames = ['calls', 'triangles', 'geometries', 'textures'];
function getMetrics(renderer) {
return {
calls: renderer.info.render.calls,
triangles: renderer.info.render.triangles,
geometries: renderer.info.memory.geometries,
textures: renderer.info.memory.textures,
};
}
function formatMetrics(data) {
return Object.entries(data)
.map(([key, value]) => `${key}: ${value}`)
.join('\n');
}
function updateHUD() {
const data = getMetrics(renderer);
hudElement.textContent = formatMetrics(data);
}5.4 Matriz de escalabilidad
| Escala | Líneas | Estructura | Cuándo migrar |
|---|---|---|---|
| Tiny | < 50 | Inline + función | Ahora (proyecto actual) |
| Small | 50-150 | Inline + helpers | Cuando agregues 5 métricas |
| Medium | 150-500 | Clase PerformanceMonitor | Cuando agregues UI interactivo |
| Large | 500+ | Componente React/Vue | Cuando sea panel complejo |
6. Código Completo Implementación Recomendada
6.1 index.html (cambios mínimos)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebGL Landing Page</title>
<style>
body { margin: 0; }
canvas { display: block; }
#hud {
position: fixed;
top: 10px;
left: 10px;
font-family: 'Courier New', monospace;
font-size: 12px;
color: #00ff00;
background: rgba(0, 0, 0, 0.7);
padding: 8px 12px;
border-radius: 4px;
line-height: 1.6;
z-index: 100;
pointer-events: none;
}
</style>
</head>
<body>
<div id="hud"></div>
<script src="https://threejs.org/build/three.js"></script>
<script src="script.js"></script>
</body>
</html>6.2 script.js (cambios estratégicos)
// Initialize scene, camera, and renderer
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// Create a plane
const geometry = new THREE.PlaneGeometry(20, 20);
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00, side: THREE.DoubleSide });
const plane = new THREE.Mesh(geometry, material);
scene.add(plane);
// Position the camera
camera.position.z = 5;
// ==================== HUD SETUP ====================
const hudElement = document.getElementById('hud');
const metrics = {
lastTime: performance.now(),
frameCount: 0,
fps: 0,
};
function updateHUD() {
const calls = renderer.info.render.calls;
const triangles = renderer.info.render.triangles;
hudElement.innerHTML = `
<div>FPS: ${metrics.fps}</div>
<div>Draw Calls: ${calls}</div>
<div>Triangles: ${triangles}</div>
`;
}
// Animation loop
function animate() {
requestAnimationFrame(animate);
renderer.render(scene, camera);
// Update FPS every 100ms
const now = performance.now();
metrics.frameCount++;
if (now - metrics.lastTime >= 100) {
metrics.fps = Math.round((metrics.frameCount * 1000) / (now - metrics.lastTime));
metrics.lastTime = now;
metrics.frameCount = 0;
}
// Update HUD display
updateHUD();
}
animate();
// Handle window resize
window.addEventListener('resize', () => {
const width = window.innerWidth;
const height = window.innerHeight;
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height);
});Cambios exactos:
- Agregado:
<div id="hud"></div>en HTML - Agregado: Styles para
#huden<style> - Agregado: Objeto
metricsdespués de crear renderer - Agregado: Función
updateHUD() - Agregado: Lógica FPS en
animate() - Agregado: Llamada a
updateHUD()en loop
Total nuevas líneas: ~40
Líneas originales preservadas: 32 sin cambios
7. Troubleshooting Común
¿Por qué renderer.info muestra valores raros?
Q: Creo ver calls: 0 o valores muy bajos
A: Asegúrate de leer la métrica DESPUÉS de renderer.render():
// ❌ INCORRECTO
function animate() {
console.log(renderer.info.render.calls); // Lectura temprana
renderer.render(scene, camera);
}
// ✓ CORRECTO
function animate() {
renderer.render(scene, camera);
console.log(renderer.info.render.calls); // Lectura después de renderizar
}El FPS es incorrecto
Q: El FPS no coincide con lo esperado
A: Verifica que calculas promedio, no instantáneo:
// ✓ Correcto: promedio cada 100ms
if (now - metrics.lastTime >= 100) {
metrics.fps = Math.round((metrics.frameCount * 1000) / (now - metrics.lastTime));
}
// ❌ Incorrecto: FPS instantáneo (parpadea)
metrics.fps = Math.round(1000 / deltaTime);El HUD parpadea o actualiza erraticamente
Q: El texto del HUD pestañea
A: Probablemente cambias innerHTML con valores que no varían. Optimiza:
// Almacena valores previos
let lastCalls = -1;
let lastFPS = -1;
function updateHUD() {
const calls = renderer.info.render.calls;
// Solo actualizar si cambió
if (calls !== lastCalls || metrics.fps !== lastFPS) {
hudElement.innerHTML = `
<div>FPS: ${metrics.fps}</div>
<div>Draw Calls: ${calls}</div>
`;
lastCalls = calls;
lastFPS = metrics.fps;
}
}8. Checklist de Implementación
- Agregar
<div id="hud"></div>aindex.html - Agregar estilos CSS para
#hud - Crear objeto
metricsenscript.js - Implementar función
updateHUD() - Agregar lógica FPS en el loop
- Llamar
updateHUD()en cada frame - Abrir DevTools y verificar
renderer.info.render.calls - Verificar que HUD aparece arriba-izquierda con valores correctos
- Redimensionar ventana para confirmar responsividad
- Probar en navegador diferente (Firefox, Safari)
9. Siguiente Paso Recomendado
Una vez implementado el HUD básico, considera agregar:
-
Toggle (mostrar/ocultar): Tecla
Dpara debug modewindow.addEventListener('keydown', (e) => { if (e.key === 'd' || e.key === 'D') { hudElement.style.display = hudElement.style.display === 'none' ? 'block' : 'none'; } }); -
Histórico de FPS: Gráfico mini con últimos 60 frames
metrics.fpsHistory = []; metrics.fpsHistory.push(metrics.fps); if (metrics.fpsHistory.length > 60) metrics.fpsHistory.shift(); -
Profiling: Medir tiempo de
renderer.render()conperformance.now()const t0 = performance.now(); renderer.render(scene, camera); const renderTime = performance.now() - t0; -
Export a JSON: Guardar métricas para análisis
const export = { fps: metrics.fps, calls: renderer.info.render.calls, timestamp: Date.now() };
Resumen Ejecutivo
| Aspecto | Recomendación |
|---|---|
| Ubicación HUD | Arriba-izquierda, fixed position, semi-opaco |
| Estructura | Inline (no necesita clase aún) |
| Actualización | Cada frame en el animation loop |
| Método DOM | innerHTML con divs (legible, performance suficiente) |
| Métricas iniciales | FPS, Draw Calls, Triangles |
| Escalabilidad | Usar objeto HUDMetrics con helpers para agregar más tarde |
| Performance | 0% impacto en FPS (DOM updates son mínimas) |
| Testing | Console asserts para verificar valores sensatos |
Tiempo estimado de implementación: 15 minutos
Líneas de código a agregar: ~40 (43% aumento, manteniendo legibilidad)
Complejidad: Baja (sin dependencias nuevas)