Introducción práctica a Three.js para renderizado 3D en el navegador

¿Qué es Three.js?

Three.js es una biblioteca de código abierto escrita en JavaScript que simplifica la creación de gráficos 3D interactivos directamente en el navegador web. Internamente utiliza la API WebGL del navegador, pero abstrae la complejidad de escribir shaders y código matemático de computación gráfica de bajo nivel, permitiendo a los desarrolladores construir escenas 3D con mucha menos curva de aprendizaje.

Sitio oficial: https://threejs.org/

Los tres pilares fundamentales

Toda aplicación Three.js requiere tres componentes esenciales:

  • Escena (Scene): Actúa como contenedor donde se colocan todos los objetos 3D, luces y cámaras.
  • Cámara (Camera): Define el punto de observación, determinando qué porción de la escena es visible y cómo se proyecta en pantalla.
  • Renderizador (Renderer): Toma la escena y la cámara, calcula la perspectiva y dibuja el resultado final en un elemento canvas del DOM.

Configuración inicial del entorno

El siguiente ejemplo muestra cómo inicializar una escena básica con un color de fondo personalizado:

// Importar la biblioteca principal
import * as THREE from 'three'

// Crear la escena como contenedor principal
const worldScene = new THREE.Scene();
worldScene.background = new THREE.Color(0x1a1a2e);

// Configurar la cámara perspectiva
const viewCamera = new THREE.PerspectiveCamera(
  60,
  window.innerWidth / window.innerHeight,
  0.1,
  2000
);

// Inicializar el renderizador con antialiasing
const canvasRenderer = new THREE.WebGLRenderer({ antialias: true });
canvasRenderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(canvasRenderer.domElement);

// Posicionar la cámara y renderizar
viewCamera.position.z = 8;
canvasRenderer.render(worldScene, viewCamera);

Cosntrucción de un cubo tridimensional

Para crear un objeto visible en Three.js se necesitan dos componentes: una geometría que define la forma y un material que define el aspecto visual. Ambos se combinan en una malla (Mesh):

import * as THREE from 'three'

const mainScene = new THREE.Scene();
const cam = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
const glRenderer = new THREE.WebGLRenderer();
glRenderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(glRenderer.domElement);

// Definir la geometría del cubo (ancho, alto, profundidad en unidades Three.js)
const boxShape = new THREE.BoxGeometry(1.5, 1.5, 1.5);

// Crear un material con color sólido
const surfaceMat = new THREE.MeshBasicMaterial({ color: 0x00cc88 });

// Ensamblar la malla combinando geometría y material
const boxObject = new THREE.Mesh(boxShape, surfaceMat);

// Insertar el objeto en la escena
mainScene.add(boxObject);

// Alejar la cámara para poder ver el cubo
cam.position.z = 5;

glRenderer.render(mainScene, cam);

Comprendiendo la cámara perspectiva

La cámara perspectiva simula la visión humana, donde los objetos más lejanos aparecen más pequeños. Sus parámetros principales son:

  • fov (campo de visión): Ángulo vertical de apertura. Valores más pequeños producen mayor zoom y objetos más grandes; valores más amplios muestran mayor área.
  • aspect (relación de aspecto): Debe coincidir con la proporción del canvas para evitar distorsiones.
  • near (plano cercano): Distancia mínima desde la cámara donde los objetos son visibles.
  • far (plano lejano): Distancia máxima de renderizado. Los objetos más allá de este plano no se dibujan.
const viewCamera = new THREE.PerspectiveCamera(
  60,                                    // Ángulo de visión vertical
  window.innerWidth / window.innerHeight, // Relación de aspecto
  0.1,                                   // Plano cercano
  1500                                   // Plano lejano
);

Mostrar ejes de coordenadas

El helper de ejes es una herramienta de depuración muy útil que dibuja tres líneas representando los ejes X (rojo), Y (verde) y Z (azul):

function attachAxes(sceneRef, length) {
  const axesGuide = new THREE.AxesHelper(length);
  sceneRef.add(axesGuide);
}

// Agregar ejes de longitud 4 a la escena
attachAxes(mainScene, 4);

Se pueden agregar ejes tanto a la escena (coordenadas del mundo) como a objetos individuales (coordenadas locales del objeto).

Controles orbitales interactivos

Los controles orbitales permiten al usuario manipular la cámara mediante el ratón: arrastrar con el botón izquierdo para rotar, usar la rueda para acercar/alejar, y arrastrar con el botón derecho para desplazar.

import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

let orbitCtrl;

function setupOrbitControls(cameraRef, rendererElement) {
  orbitCtrl = new OrbitControls(cameraRef, rendererElement);
  orbitCtrl.enableDamping = true;
  orbitCtrl.dampingFactor = 0.1;
  orbitCtrl.minDistance = 2;
  orbitCtrl.maxDistance = 20;
}

function startRenderLoop(sceneRef, cameraRef, rendererRef) {
  function tick() {
    requestAnimationFrame(tick);
    orbitCtrl.update();
    rendererRef.render(sceneRef, cameraRef);
  }
  tick();
}

Es fundamental invocar orbitCtrl.update() en cada fotograma para que el efecto de amortiguación funcione correctamente.

Adaptación responsiva de la ventana

Para que la escena se redimensione correctamente cuando el usuario cambia el tamaño del navegador:

function handleWindowResize(cameraRef, rendererRef) {
  window.addEventListener('resize', () => {
    const width = window.innerWidth;
    const height = window.innerHeight;

    cameraRef.aspect = width / height;
    cameraRef.updateProjectionMatrix();

    rendererRef.setSize(width, height);
  });
}

Geometrías y materiales

Three.js proporciona geometrías incorporadas como BoxGeometry, SphereGeometry, PlaneGeometry, entre otras. Para formas irregulares, se pueden importar modelos 3D creados con software de modelado.

Los materiales controlan la apariencia visual. MeshBasicMaterial no responde a la iluminación, mientras que MeshStandardMaterial sí interactúa con las luces de la escena.

Múltiples cubos con colores aleatorios

Se pueden generar varios objetos con propiedades aleatorias para visualizaciones dinámicas:

const objectPool = [];

function generateRandomBoxes(quantity) {
  for (let idx = 0; idx < quantity; idx++) {
    const r = Math.floor(Math.random() * 256);
    const g = Math.floor(Math.random() * 256);
    const b = Math.floor(Math.random() * 256);

    const dims = {
      w: Math.random() * 2 + 0.5,
      h: Math.random() * 2 + 0.5,
      d: Math.random() * 2 + 0.5
    };

    const geo = new THREE.BoxGeometry(dims.w, dims.h, dims.d);
    const mat = new THREE.MeshBasicMaterial({
      color: `rgb(${r},${g},${b})`
    });
    const mesh = new THREE.Mesh(geo, mat);

    mesh.position.set(
      (Math.random() - 0.5) * 12,
      (Math.random() - 0.5) * 12,
      (Math.random() - 0.5) * 12
    );

    mainScene.add(mesh);
    objectPool.push(mesh);
  }
}

generateRandomBoxes(8);

Transformaciones: traslación, rotación y escala

function applyTransforms(targetMesh) {
  // Traslación en el sistema de coordenadas del padre
  targetMesh.position.set(3, 1, -2);

  // Rotación en radianes alrededor de los ejes locales
  targetMesh.rotation.x = Math.PI / 6;
  targetMesh.rotation.y = Math.PI / 4;

  // Escala a lo largo de los ejes locales (el centro permanece fijo)
  targetMesh.scale.set(1.5, 0.8, 2);
}

function animateRotation(targetMesh) {
  targetMesh.rotation.x += 0.008;
  targetMesh.rotation.y += 0.012;
}

Monitoreo de rendimiento con Stats

Stats.js muestra métricas de rendimiento en tiempo real (FPS, tiempo de fotograma, uso de memoria):

import Stats from 'three/addons/libs/stats.module.js';

function initPerformanceMonitor() {
  const monitor = new Stats();
  monitor.setMode(0); // 0=FPS, 1=ms por fotograma, 2=memoria
  monitor.domElement.style.position = 'fixed';
  monitor.domElement.style.left = '0px';
  monitor.domElement.style.top = '0px';
  document.body.appendChild(monitor.domElement);
  return monitor;
}

Cubos multicolor con seis caras

Asignando un array de materiales, cada cara del cubo puede tener un color diferente:

const colors = ['#e74c3c', '#2ecc71', '#3498db', '#f39c12', '#9b59b6', '#1abc9c'];
const faceMaterials = colors.map(c => new THREE.MeshBasicMaterial({ color: c }));

const multiFaceBox = new THREE.Mesh(
  new THREE.BoxGeometry(2, 2, 2),
  faceMaterials
);
mainScene.add(multiFaceBox);

Geometría esférica

function buildSphere() {
  const sphereGeo = new THREE.SphereGeometry(2, 64, 32);
  const sphereMat = new THREE.MeshStandardMaterial({
    color: 0x2196f3,
    roughness: 0.3,
    metalness: 0.7
  });
  const sphereMesh = new THREE.Mesh(sphereGeo, sphereMat);
  mainScene.add(sphereMesh);
  return sphereMesh;
}

Eliminación de objetos y liberación de memoria

Al eliminar un objeto de la escena, es importante liberar también la memoria de la geometría y el material:

function disposeObject(mesh, sceneRef) {
  if (mesh.geometry) mesh.geometry.dispose();
  if (mesh.material) {
    if (Array.isArray(mesh.material)) {
      mesh.material.forEach(mat => mat.dispose());
    } else {
      mesh.material.dispose();
    }
  }
  sceneRef.remove(mesh);
}

Raycasting para interacción con el ratón

El raycasting proyecta un rayo invisible desde la posición del ratón en pantalla hacia la escena 3D, detectando qué objetos son intersectados. Las coordenadas de pantalla deben normalizarse al rango [-1, +1]:

function setupClickInteraction(cameraRef, targets, sceneRef) {
  const ray = new THREE.Raycaster();
  const cursor = new THREE.Vector2();

  window.addEventListener('dblclick', (event) => {
    // Convertir coordenadas de pantalla a coordenadas normalizadas
    cursor.x = (event.clientX / window.innerWidth) * 2 - 1;
    cursor.y = -(event.clientY / window.innerHeight) * 2 + 1;

    ray.setFromCamera(cursor, cameraRef);

    const hits = ray.intersectObjects(targets);
    if (hits.length === 0) return;

    const nearestHit = hits[0].object;
    disposeObject(nearestHit, sceneRef);

    const index = targets.indexOf(nearestHit);
    if (index > -1) targets.splice(index, 1);
  });
}

Tipos de iluminación

Luz ambiental

Ilumina uniformemente todas las superficies de la escena sin dirección ni sombras. Actúa como relleno base:

const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
mainScene.add(ambientLight);

Luz direccional

Simula una fuente de luz distante como el sol, con rayos paralelos. Produce sombras definidas:

const sunLight = new THREE.DirectionalLight(0xffffff, 1.5);
sunLight.position.set(5, 10, 7);
mainScene.add(sunLight);

// Helper para visualizar la dirección de la luz
const sunHelper = new THREE.DirectionalLightHelper(sunLight, 2);
mainScene.add(sunHelper);

Luz puntual

Emite luz en todas las direcciones desde un punto específico, como una bombilla:

const bulbLight = new THREE.PointLight(0xffaa00, 8, 50);
bulbLight.position.set(-3, 4, 2);
mainScene.add(bulbLight);

const bulbHelper = new THREE.PointLightHelper(bulbLight, 0.5);
mainScene.add(bulbHelper);

Foco (SpotLight)

Proyecta luz cónica desde un punto hacia una dirección específica. Permite configurar el ángulo del cono y la difuminación del borde:

const spotlight = new THREE.SpotLight(0xffffff, 15, 80, Math.PI / 5, 0.6, 1);
spotlight.position.set(4, 6, 4);
spotlight.castShadow = true;
mainScene.add(spotlight);

const spotHelper = new THREE.SpotLightHelper(spotlight);
mainScene.add(spotHelper);

Sombras en Three.js

Para activar el sistema de sombras se requiere configuración en tres niveles:

function enableShadowSystem(rendererRef, lightRef, casterMesh, receiverMesh) {
  // 1. Habilitar sombras en el renderizador
  rendererRef.shadowMap.enabled = true;

  // 2. La luz debe calcular sombras
  lightRef.castShadow = true;

  // 3. El objeto que proyecta sombra
  casterMesh.castShadow = true;

  // 4. El objeto que recibe sombra
  receiverMesh.receiveShadow = true;
}

Renderizadores CSS: 2D y 3D

Three.js permite integrar elementos HTML/CSS dentro de la escena 3D mediante renderizadores CSS especiales:

  • CSS3DRenderer: Los elementos DOM se transforman en objetos 3D con posición, rotación y escala completas. No siempre miran hacia la cámara.
  • CSS2DRenderer: Los elementos siempre se orientan hacia la cámara y mantienen tamaño constante independiente de la distancia.
import { CSS3DRenderer, CSS3DObject } from 'three/addons/renderers/CSS3DRenderer.js';
import { CSS2DRenderer, CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js';

function setupCSSRenderers() {
  // Renderizador 3D para DOM
  const css3d = new CSS3DRenderer();
  css3d.setSize(window.innerWidth, window.innerHeight);
  css3d.domElement.style.position = 'fixed';
  css3d.domElement.style.pointerEvents = 'none';
  document.body.appendChild(css3d.domElement);

  // Renderizador 2D para DOM
  const css2d = new CSS2DRenderer();
  css2d.setSize(window.innerWidth, window.innerHeight);
  css2d.domElement.style.position = 'fixed';
  css2d.domElement.style.pointerEvents = 'none';
  document.body.appendChild(css2d.domElement);

  return { css3d, css2d };
}

function createLabel3D(text, position) {
  const label = document.createElement('div');
  label.textContent = text;
  label.style.color = '#ff6b6b';
  label.style.fontSize = '18px';
  label.style.fontWeight = 'bold';

  const obj3d = new CSS3DObject(label);
  obj3d.position.copy(position);
  obj3d.scale.set(1/16, 1/16, 1/16);
  return obj3d;
}

Objetos sprite (sprites)

Los sprites son planos 2D que siempre miran hacia la cámara. Son ideales para iconos, etiquetas o efectos de partículas:

function createSprite(texturePath, position) {
  const loader = new THREE.TextureLoader();
  const texture = loader.load(texturePath);
  const spriteMat = new THREE.SpriteMaterial({ map: texture, transparent: true });
  const spriteObj = new THREE.Sprite(spriteMat);
  spriteObj.position.copy(position);
  spriteObj.scale.set(2, 2, 1);
  return spriteObj;
}

const markerSprite = createSprite('/icon-marker.png', new THREE.Vector3(0, 3, 0));
mainScene.add(markerSprite);

Ejemplo integrado: escena completa

El siguiente código combina todos los conceptos anteriores en una escena funcional:

import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import Stats from 'three/addons/libs/stats.module.js';

// Variables globales
let sceneWorld, mainCamera, glRenderer, orbitControls, perfMonitor;
let rotatingBoxes = [];

function bootstrapApplication() {
  // Escena
  sceneWorld = new THREE.Scene();
  sceneWorld.background = new THREE.Color(0x0d1117);

  // Cámara
  mainCamera = new THREE.PerspectiveCamera(
    65,
    window.innerWidth / window.innerHeight,
    0.1,
    5000
  );
  mainCamera.position.set(8, 6, 12);

  // Renderizador
  glRenderer = new THREE.WebGLRenderer({ antialias: true });
  glRenderer.setSize(window.innerWidth, window.innerHeight);
  glRenderer.shadowMap.enabled = true;
  document.body.appendChild(glRenderer.domElement);

  // Controles orbitales
  orbitControls = new OrbitControls(mainCamera, glRenderer.domElement);
  orbitControls.enableDamping = true;
  orbitControls.dampingFactor = 0.08;

  // Monitor de rendimiento
  perfMonitor = new Stats();
  perfMonitor.setMode(0);
  document.body.appendChild(perfMonitor.domElement);

  // Ejes de referencia
  sceneWorld.add(new THREE.AxesHelper(6));

  // Iluminación
  setupLights();

  // Suelo
  createGroundPlane();

  // Objetos
  spawnRotatingBoxes(6);

  // Responsividad
  registerResizeHandler();

  // Iniciar bucle de renderizado
  requestAnimationFrame(renderCycle);
}

function setupLights() {
  const hemispherical = new THREE.HemisphereLight(0x87ceeb, 0x362d1b, 0.6);
  sceneWorld.add(hemispherical);

  const directional = new THREE.DirectionalLight(0xfff4e6, 1.2);
  directional.position.set(6, 10, 8);
  directional.castShadow = true;
  sceneWorld.add(directional);
}

function createGroundPlane() {
  const groundGeo = new THREE.PlaneGeometry(30, 30);
  const groundMat = new THREE.MeshStandardMaterial({
    color: 0x2d3436,
    roughness: 0.8
  });
  const ground = new THREE.Mesh(groundGeo, groundMat);
  ground.rotation.x = -Math.PI / 2;
  ground.position.y = -0.5;
  ground.receiveShadow = true;
  sceneWorld.add(ground);
}

function spawnRotatingBoxes(count) {
  const palette = [0xe17055, 0x00b894, 0x0984e3, 0xfdcb6e, 0x6c5ce7, 0xe84393];

  for (let i = 0; i < count; i++) {
    const size = Math.random() * 1.5 + 0.5;
    const geo = new THREE.BoxGeometry(size, size, size);
    const mat = new THREE.MeshStandardMaterial({
      color: palette[i % palette.length],
      roughness: 0.4,
      metalness: 0.6
    });
    const mesh = new THREE.Mesh(geo, mat);
    mesh.castShadow = true;

    mesh.position.set(
      (Math.random() - 0.5) * 10,
      size / 2,
      (Math.random() - 0.5) * 10
    );

    sceneWorld.add(mesh);
    rotatingBoxes.push(mesh);
  }
}

function registerResizeHandler() {
  window.addEventListener('resize', () => {
    mainCamera.aspect = window.innerWidth / window.innerHeight;
    mainCamera.updateProjectionMatrix();
    glRenderer.setSize(window.innerWidth, window.innerHeight);
  });
}

function renderCycle() {
  requestAnimationFrame(renderCycle);
  orbitControls.update();
  perfMonitor.update();

  // Animar cada cubo con velocidad de rotación aleatoria
  rotatingBoxes.forEach((box, idx) => {
    box.rotation.x += 0.005 + idx * 0.002;
    box.rotation.y += 0.008 + idx * 0.001;
  });

  glRenderer.render(sceneWorld, mainCamera);
}

bootstrapApplication();

Carga de modelos GLTF/GLB

Three.js permite cargar modelos 3D exportados en formato GLTF (JSON) o GLB (binario) mediante el cargador GLTFLoader:

import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

function importModel(filePath, sceneRef, callback) {
  const modelLoader = new GLTFLoader();

  modelLoader.load(
    filePath,
    (gltfData) => {
      const modelRoot = gltfData.scene;

      // Ajustar escala y posición según sea necesario
      modelRoot.scale.set(1, 1, 1);
      modelRoot.position.set(0, 0, 0);

      sceneRef.add(modelRoot);

      // Procesar animaciones si existen
      if (gltfData.animations.length > 0) {
        const mixer = new THREE.AnimationMixer(modelRoot);
        gltfData.animations.forEach((clip) => {
          mixer.clipAction(clip).play();
        });
      }

      if (callback) callback(modelRoot);
    },
    (progress) => {
      const percent = (progress.loaded / progress.total) * 100;
      console.log(`Carga: ${percent.toFixed(1)}%`);
    },
    (error) => {
      console.error('Error al cargar el modelo:', error);
    }
  );
}

Los archivos de modelo deben ubicarse en la carpeta pública del servidor para ser accesibles como recursos estáticos. En proyectos con Vite, se pueden importar directamente configurando assetsInclude en la configuración.

Aplicación de texturas a superficies

Las texturas se cargan como imágenes y se asignan a los matreiales para dar apariencia realista a las superficies:

function applyTextureToObject(mesh, imagePath) {
  const textureLoader = new THREE.TextureLoader();
  const texture = textureLoader.load(imagePath);

  // Configurar el espacio de color sRGB para colores correctos
  texture.colorSpace = THREE.SRGBColorSpace;

  // Repetir la textura en ambas direcciones
  texture.wrapS = THREE.RepeatWrapping;
  texture.wrapT = THREE.RepeatWrapping;
  texture.repeat.set(4, 4);

  mesh.material = new THREE.MeshStandardMaterial({
    map: texture,
    roughness: 0.7
  });
}

Recursos y proyectos de práctica recomendados

Para consolidar los conocimientos de Three.js, se recomiendan los siguientes recursos:

Entre los proyectos prácticos sugeridos se incluyen: escenas con iluminación y sombras, carga y manipulación de modelos 3D, visualización de datos geoespaciales en globo terráqueo, entornos interactivos de interior, y juegos simples con físicas básicas.

Etiquetas: three.js WebGL JavaScript3D SceneGraph WebGLRenderer

Publicado el 6-2 09:05