wandres.dev
PRIMER PROYECTO · Escena, cámara, renderer

Escena, cámara y renderer: la tríada mínima

El proyecto más pequeño que dibuja algo, con el HTML incluido, qué hace cada una de las tres piezas y la lista de comprobación para cuando no aparece nada.

⏱ 17 min

Tres objetos bastan para dibujar en pantalla: una escena que contiene las cosas, una cámara que define desde dónde se mira, y un renderer que convierte ambas en píxeles. Ninguno de los tres hace nada sin los otros dos, y entender qué responsabilidad tiene cada uno es lo que permite depurar el caso más frecuente de todos los principios: que no se vea absolutamente nada y no haya ningún error en consola.

🎯 Al terminar esta lección sabrás
  • Montar un proyecto ejecutable con las tres piezas y sin herramientas de compilación.
  • Explicar qué responsabilidad tiene la escena, la cámara y el renderer.
  • Enumerar las opciones del renderer que hay que decidir al crearlo.
  • Diagnosticar con una lista cerrada por qué una escena aparece vacía.

El proyecto completo

Dos archivos y ninguna herramienta de compilación. El mapa de importaciones del HTML permite escribir import ... from 'three' en el módulo sin empaquetador.

<!DOCTYPE html>
<html lang="es">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Primer proyecto</title>
    <style>
      html, body { margin: 0; height: 100%; background: #11111b; }
      canvas { display: block; }
    </style>
  </head>
  <body>
    <script type="importmap">
      {
        "imports": {
          "three": "https://unpkg.com/three@0.184.0/build/three.module.js",
          "three/addons/": "https://unpkg.com/three@0.184.0/examples/jsm/"
        }
      }
    </script>
    <script type="module" src="./main.js"></script>
  </body>
</html>
import * as THREE from 'three';

// 1. La escena: el contenedor y las propiedades globales del entorno.
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x11111b);

// 2. La camara: desde donde se mira y con que apertura.
const camera = new THREE.PerspectiveCamera(
  50,                                       // campo de vision vertical en grados
  window.innerWidth / window.innerHeight,   // relacion de aspecto
  0.1,                                      // plano cercano
  100                                       // plano lejano
);
camera.position.set(2.5, 2, 3.5);
camera.lookAt(0, 0, 0);

// 3. El renderer: quien traduce escena y camara a ordenes de dibujo.
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
document.body.appendChild(renderer.domElement);

// Contenido: una malla necesita geometria y material.
const malla = new THREE.Mesh(
  new THREE.IcosahedronGeometry(1, 0),
  new THREE.MeshStandardMaterial({ color: 0x89b4fa, roughness: 0.4, flatShading: true })
);
scene.add(malla);

// MeshStandardMaterial responde a la luz: sin luces, negro.
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
const direccional = new THREE.DirectionalLight(0xffffff, 3);
direccional.position.set(3, 4, 2);
scene.add(direccional);

// Referencias visuales que conviene tener durante el desarrollo.
scene.add(new THREE.GridHelper(10, 10, 0x45475a, 0x313244));
scene.add(new THREE.AxesHelper(2));

renderer.render(scene, camera);

Sirve el directorio con cualquier servidor estático —los módulos de JavaScript no funcionan abriendo el archivo directamente— y tendrás un icosaedro iluminado sobre una rejilla. AxesHelper dibuja los tres ejes en rojo, verde y azul para X, Y y Z; es la brújula que evita perderse en los primeros días.

Qué hace cada pieza

La escena es un Object3D especial que actúa de raíz del árbol. Además de contener, tiene propiedades que afectan a todo lo que hay dentro: background, que puede ser un color, una textura o un mapa de entorno; environment, que ilumina todos los materiales físicos con una imagen; fog, que atenúa por distancia; y overrideMaterial, que sustituye el material de todo lo que se dibuje, muy útil para depurar.

La cámara define la transformación de vista y la de proyección. Es un Object3D como cualquier otro, así que tiene posición, rotación y jerarquía; puedes añadirla como hija de otro objeto para que lo siga. Sus cuatro parámetros describen el volumen visible: el campo de visión vertical en grados, la relación de aspecto, y las distancias a los planos de recorte cercano y lejano. Todo lo que quede fuera de ese volumen no se dibuja.

El renderer es el objeto pesado. Al construirlo se crea el contexto gráfico, y con él una serie de recursos de sistema que no son gratis. La consecuencia práctica es que debe haber uno solo por aplicación: crear varios es la forma más rápida de agotar los contextos disponibles, que en la mayoría de navegadores están limitados a unos pocos. Si necesitas varias vistas, se hacen con varias cámaras y varias regiones de dibujo sobre el mismo renderer, no con varios renderers.

Las opciones del constructor que hay que decidir de entrada son cuatro. antialias activa el suavizado de bordes por hardware, que se ve mucho y cuesta memoria y ancho de banda. alpha hace que el fondo del canvas sea transparente, para superponerlo sobre HTML. canvas permite usar un elemento existente en lugar de crear uno. Y powerPreference sugiere al navegador si prefieres la GPU dedicada o la integrada, en equipos que tienen ambas.

Cuando no se ve nada

Es el primer muro de todo el mundo, y tiene la particularidad de que la consola no ayuda: no hay error, hay un rectángulo vacío. La lista siguiente está ordenada por frecuencia real y resuelve prácticamente todos los casos.

¿Has llamado a render? Sin esa llamada no se dibuja nada, y si la llamas una sola vez, cualquier cambio posterior no se refleja.

¿La cámara está dentro del objeto? Con la cámara en el origen y un objeto en el origen, estás dentro de él, y las caras interiores se descartan por orientación. Muévela.

¿El objeto está dentro de los planos de recorte? Un plano cercano de 0.1 y un objeto a 0.05 de la cámara no se ve. Un plano lejano de 100 y un objeto a 500, tampoco.

¿El material necesita luz y no la hay? MeshStandardMaterial, MeshPhysicalMaterial, MeshLambertMaterial y MeshPhongMaterial se ven negros sin luces. MeshBasicMaterial y MeshNormalMaterial no necesitan ninguna, y por eso son los mejores para descartar este caso: cambia el material a MeshNormalMaterial y si aparece, el problema era la iluminación.

¿La escala es razonable? Un modelo exportado en milímetros dentro de una escena en metros es mil veces más grande de lo que esperas, y lo que ves es el interior de una de sus caras.

¿Se añadió a la escena? Crear una malla no la añade. Y si la añadiste a un grupo, comprueba que el grupo esté en la escena.

¿El canvas tiene tamaño? Un canvas dentro de un contenedor de altura cero mide cero píxeles. Se detecta al instante inspeccionando el elemento.

Una herramienta que acelera mucho este diagnóstico: scene.overrideMaterial = new THREE.MeshBasicMaterial({ wireframe: true }). Con eso se dibuja todo en alambre, sin luces y sin descarte de caras que confunda, y se ve de inmediato si el problema es de geometría, de posición o de material.

La cámara no hace falta añadirla a la escena, salvo cuando sí hace falta

Uno de los primeros descubrimientos raros de Three.js es que la escena se dibuja perfectamente sin haber hecho scene.add(camera). Es correcto: el renderer recibe la cámara como segundo argumento de render, actualiza su matriz por su cuenta si no tiene padre, y no necesita encontrarla en el árbol. Mucha gente concluye de ahí que la cámara vive fuera del grafo de escena, y esa conclusión produce un bug que desconcierta muchísimo. Si en algún momento cuelgas algo de la cámara —una luz que acompañe al observador, un modelo de arma en primera persona, un elemento de interfaz en el espacio 3D— ese hijo no se dibujará. El motivo es que el renderer recorre el árbol partiendo de la escena para recoger todo lo que hay que dibujar, y si la cámara no está en ese árbol, sus descendientes son inalcanzables. La solución es una línea, scene.add(camera), y a partir de ahí todo funciona; pero el síntoma —un objeto perfectamente construido, con material correcto, posición correcta, que sencillamente no existe— manda a cualquiera a revisar todo lo demás primero. Merece la pena convertirlo en costumbre: añade siempre la cámara a la escena, no cuesta nada y elimina la clase entera de problemas. Y de paso recuerda la regla general de la que este caso es un ejemplo: en Three.js, lo que no cuelga de la escena no existe para el renderer.

Limpiar cuando toca

El proyecto de arriba vive hasta que se cierra la pestaña, así que no necesita limpieza. En cuanto la escena viva dentro de un componente que se monta y se desmonta, sí, y conviene saber desde ya cuáles son las tres cosas que hay que soltar.

export function destruir(renderer, scene) {
  // 1. Parar el bucle antes que nada.
  renderer.setAnimationLoop(null);

  // 2. Liberar los recursos de GPU de cada objeto dibujable.
  scene.traverse((objeto) => {
    if (objeto.geometry) objeto.geometry.dispose();

    const materiales = Array.isArray(objeto.material) ? objeto.material : [objeto.material];
    for (const material of materiales) {
      if (!material) continue;
      for (const clave of Object.keys(material)) {
        const valor = material[clave];
        if (valor && valor.isTexture) valor.dispose();
      }
      material.dispose();
    }
  });

  // 3. Cerrar el contexto grafico y quitar el canvas del documento.
  renderer.dispose();
  renderer.domElement.remove();
}

Ese fragmento no es una curiosidad de higiene: es lo que separa una aplicación que se puede navegar durante una hora de una que va agotando la memoria de vídeo cada vez que se entra en una vista. La primera lección del track ya explicaba por qué el recolector de basura no puede hacerlo por ti.

Lo que falta para que la escena deje de ser una imagen fija es el bucle de render.