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ónCuándoEjemplo
Inline< 100 LOC, una escenaEste proyecto
Clase wrapper> 100 LOC, múltiples escenasMotor reutilizable
UI FrameworkPaneles complejos (React, Vue)Dashboard profesional
Overlay canvasNecesitas rendering 3D para HUDGrá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 calls

2.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 anterior

IMPORTANTE: 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

EscenarioCalls típicasOptimización
1 plano (este proyecto)1Óptimo
10 meshes, 1 material1Óptimo
10 meshes, 10 materiales10Malo → consolidar
100 meshes, instanciados1Óptimo (InstancedMesh)
Cielo + 50 árboles + personaje3-5Normal

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 memoria

3. 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

  1. Monitor en tiempo real: Calls, FPS, triangles
  2. Ubicación: Arriba-izquierda (esquina no invasiva)
  3. Actualización: Cada frame, sin lag
  4. Escalable: Fácil agregar más métricas

3.3 Decisiones de diseño

DecisiónOpciónElegidaRazón
UbicaciónArriba-izquierda vs. FlotanteArriba-izquierdaEstándar en game engines
FuenteMonospace vs. Sans-serifMonospaceLegibilidad números
ActualizacióninnerHTML vs. textContenttextContentMejor performance
FormatoVertical vs. HorizontalVerticalMenos espacio, más legible
FondoTransparente vs. OpacoSemi-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/pan
  • top: 10px; left: 10px → Arriba-izquierda estándar
  • pointer-events: none → No bloquea clicks en canvas
  • monospace → Números se alinean perfecto
  • rgba(0, 0, 0, 0.7) → Visible sobre fondos claros y oscuros
  • z-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étodoFPS HitCuándo usar
Actualizar cada frameNulo (~0%)Proyecto actual (simple)
Actualizar c/100msNulo (~0%)Proyecto actual (buena práctica)
Usar WebWorkerMínimoHUD muy complejo
Usar Canvas para HUDDependeSi 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

EscalaLíneasEstructuraCuándo migrar
Tiny< 50Inline + funciónAhora (proyecto actual)
Small50-150Inline + helpersCuando agregues 5 métricas
Medium150-500Clase PerformanceMonitorCuando agregues UI interactivo
Large500+Componente React/VueCuando 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:

  1. Agregado: <div id="hud"></div> en HTML
  2. Agregado: Styles para #hud en <style>
  3. Agregado: Objeto metrics después de crear renderer
  4. Agregado: Función updateHUD()
  5. Agregado: Lógica FPS en animate()
  6. 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> a index.html
  • Agregar estilos CSS para #hud
  • Crear objeto metrics en script.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:

  1. Toggle (mostrar/ocultar): Tecla D para debug mode

    window.addEventListener('keydown', (e) => {
        if (e.key === 'd' || e.key === 'D') {
            hudElement.style.display = hudElement.style.display === 'none' ? 'block' : 'none';
        }
    });
  2. 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();
  3. Profiling: Medir tiempo de renderer.render() con performance.now()

    const t0 = performance.now();
    renderer.render(scene, camera);
    const renderTime = performance.now() - t0;
  4. Export a JSON: Guardar métricas para análisis

    const export = { fps: metrics.fps, calls: renderer.info.render.calls, timestamp: Date.now() };

Resumen Ejecutivo

AspectoRecomendación
Ubicación HUDArriba-izquierda, fixed position, semi-opaco
EstructuraInline (no necesita clase aún)
ActualizaciónCada frame en el animation loop
Método DOMinnerHTML con divs (legible, performance suficiente)
Métricas inicialesFPS, Draw Calls, Triangles
EscalabilidadUsar objeto HUDMetrics con helpers para agregar más tarde
Performance0% impacto en FPS (DOM updates son mínimas)
TestingConsole 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)