Contacto

Ciudad de México, México
Disponible para proyectos remotos y presenciales.

Redes profesionales

LinkedIn · GitHub · Email
Ingeniería aplicada, resultados medibles.

Cómo integrar un motor gráfico 3D en tu sitio web (sin matar el rendimiento)

· 12 min de lectura · Samuel Torres

Superfórmula de 7 puntas dibujándose
r(t) = (|cos(7t/4)|^1.7 + |sin(7t/4)|^1.7)^(-1/0.2)

En resumen

  • Un video en base64 no se cachea aparte ni se carga en diferido; en Conqr se reemplazó por una figura 3D en vivo.
  • Un solo renderer WebGL para toda la página: un único contexto sin importar cuántas vistas haya.
  • El motor gasta 1.75 ms de JS por frame en escritorio y 8.62 ms con la CPU 4× más lenta, dentro de los 16.7 ms de un frame a 60 fps.
  • Carga diferida, pausa fuera de pantalla y en pestaña oculta, DPR limitado y calidad adaptativa.
  • Con prefers-reduced-motion o sin WebGL, una imagen fija con medidas.

La figura cromada que ves abajo no es un video. Se abulta hacia el cursor, rebota si la tocas, gira si la arrastras y, con doble clic, salta a su siguiente forma. En el celular responde al dedo. Es el motor 3D que escribí para el sitio de Conqr, que salió en vivo en conqr.mx el 30 de octubre de 2026.

El motor 3D en vivo: hover, clic con onda, arrastrar y lanzar, y doble clic para pasar por el ciclo de formas.

Antes era un video de 10 segundos metido en base64 dentro de un archivo de datos. Se veía increíble, pero no reaccionaba a nadie, no se podía editar y pesaba dentro del HTML. Este artículo cuenta cómo lo convertí en un objeto vivo sin que el sitio se arrastrara. Es para dueños de agencia, marketing y devs: la primera mitad se lee sin programar; la segunda trae código corto para copiar.

Un aviso honesto antes de empezar: el diseño visual es del equipo de diseño de Conqr. A mí me tocó el desarrollo, el motor, la arquitectura y el lanzamiento, con Claude y Claude Code como copilotos.

WebGL, Three.js y shaders, sin tecnicismos

Tres palabras que vas a oír en cualquier proyecto 3D para web:

  • WebGL es la puerta que el navegador abre hacia la tarjeta gráfica. Según MDN, es una API de JavaScript para dibujar gráficos 3D y 2D interactivos en cualquier navegador compatible, sin plugins. Piensa en ella como el idioma en el que la página le habla a la GPU.
  • Three.js es una librería que traduce ese idioma a algo humano: escenas, cámaras, luces, materiales. Si WebGL es el motor del coche, Three.js es el volante y los pedales.
  • Los shaders son programas pequeñitos que corren en la GPU, miles a la vez. El vertex shader decide dónde va cada punto de la figura; el fragment shader, de qué color se pinta cada pixel. Son como una receta que cada pixel cocina en paralelo.

La idea clave: la GPU hace el trabajo pesado. El problema suele estar alrededor: cuántas cosas dibujas, a qué resolución y si sigues dibujando cuando nadie mira.

¿Video, imagen o motor en vivo?

No todo merece un motor 3D. Antes de escribir una línea, compara las tres opciones:

ImagenVideoMotor en vivo
PesoEl más ligeroCrece con la duración y la resoluciónCódigo + librería; no crece con la duración
InteracciónNingunaPlay y pausaTotal: cursor, clic, dedo, scroll
NitidezFija a su resoluciónFija a su resolución y con compresiónSe dibuja a la resolución de cada pantalla
RendimientoCasi gratisDecodificar cuesta, sobre todo varios a la vezDepende de ti: hay que diseñarlo
Cuándo convieneContenido estático y respaldoNarrativa lineal: un recorrido, un demoLa pieza central de la marca, repetida y que invita a jugar

En Conqr la figura aparecía en el hero, dentro de las letras de con·quer, en el menú, en el cursor y en los heroes de cada página. Un video por cada aparición es mucho decodificar para algo que ni siquiera responde.

La lección del video en base64

El concepto original guardaba el video, las imágenes, los logos y el mapa en base64, dentro de archivos de datos. Base64 convierte un archivo en texto que viaja dentro del JavaScript. Así, el medio no se cachea por separado ni se puede cargar en diferido.

Aquí va un dato incómodo y honesto: no puedo decirte cuánto pesaba ese concepto. Los archivos de datos nunca llegaron al servidor, y sin ellos la página ni siquiera arranca. Lo único que pude verificar es un piso: los medios que sí llegaron por separado ocuparían ~1 MB en base64, sin contar el video. Es un mínimo, no el total. Hoy, la Home de conqr.mx transfiere 627 KB en 70 peticiones, con la figura 3D incluida.

La regla que quedó: nada se carga en base64. Los medios van como archivos, con su caché y su carga diferida.

Arquitectura: un solo renderer para toda la página

Esta fue la decisión que más importó. La figura vive en muchos lugares a la vez, y cada aparición necesita su propio sitio en el DOM: el cursor va encima del menú, la gota va detrás de las letras con mix-blend-mode: multiply, y hay máscaras y flotación.

Había dos caminos obvios y ninguno servía:

  • Un canvas WebGL por vista. Multiplica los contextos WebGL, y los navegadores limitan cuántos contextos activos puede tener una página.
  • Un canvas fijo a pantalla completa. Todo queda encima o todo debajo del contenido. Adiós a las capas del diseño.

La tercera vía: un solo WebGLRenderer sobre un canvas fuera del DOM. En cada frame, cada vista se dibuja en su recuadro y se copia con drawImage al canvas 2D de esa vista. Un único contexto WebGL sin importar cuántas vistas haya, y cada vista conserva su z-index, su máscara y su modo de fusión.

Diagrama del motor: un solo renderer WebGL fuera del DOM dibuja cada vista en su recuadro y la copia al canvas 2D de esa vista en la página
Un renderer, un contexto WebGL, muchas vistas. Cada una conserva su lugar en el DOM.

Un boceto mínimo de la idea con Three.js (escrito para este artículo):

const renderer = new THREE.WebGLRenderer({ alpha: true });
renderer.setPixelRatio(1);      // cada vista ya trae sus pixeles reales
renderer.setScissorTest(true);
const lienzo = renderer.domElement; // nunca se agrega al DOM

function dibujar(vistas) {
  for (const v of vistas) {
    if (!v.enPantalla) continue;
    const w = v.canvas.width, h = v.canvas.height;
    if (w > lienzo.width || h > lienzo.height) {
      // el lienzo solo crece: no se reserva memoria en cada frame
      renderer.setSize(Math.max(w, lienzo.width), Math.max(h, lienzo.height), false);
    }
    renderer.setViewport(0, 0, w, h);
    renderer.setScissor(0, 0, w, h);
    renderer.render(v.escena, v.camara);
    v.ctx.clearRect(0, 0, w, h);
    v.ctx.drawImage(lienzo, 0, lienzo.height - h, w, h, 0, 0, w, h);
  }
}

Los métodos setViewport, setScissor y setScissorTest están en la documentación oficial de WebGLRenderer. Alrededor de eso hay tres reglas: un solo requestAnimationFrame para todo, liberar todo al navegar y que los componentes consuman el motor, nunca lo dupliquen.

Que se vea vivo: ruido, material y física

Una esfera girando no emociona a nadie. Lo que hace que la figura parezca viva son tres capas:

  • Ruido en la forma. La forma vive en el vertex shader, con ruido simplex en dos octavas. El ruido es una variación suave y orgánica, como el oleaje: nunca se repite igual, pero tampoco salta. Dos octavas son dos olas encimadas, una grande y lenta, otra chica y rápida.
  • Material físico. Metal con un entorno de paneles azules generado en código. No se descarga ningún mapa HDR: el reflejo se calcula, no se baja.
  • Física amortiguada. Cuando la tocas, rebota y se calma, como un resorte con freno. Sin amortiguación, la figura vibraría para siempre; con demasiada, se sentiría muerta.

Que lo edite el equipo: un widget con controles

Un efecto que solo el developer puede tocar envejece en semanas. En Conqr la regla fue: todo lo que pueda vivir en Elementor vive en Elementor, y lo que no, es un widget con controles, nunca código suelto.

La figura cromada es un widget de Elementor. El equipo lo arrastra a cualquier página y, desde el panel, elige el uso, el tamaño, si es jugable y si lleva halo. Sin abrir un archivo. Por debajo, el widget solo le pide al motor compartido que monte una vista nueva; no crea su propio contexto.

El plugin del sitio terminó con 23 widgets y 14 behaviors. De 162 secciones revisadas, ninguna quedó sin poder editarse. Si quieres ver cómo se armó todo eso, está en el caso completo de Conqr.

Rendimiento: el presupuesto de 16.7 ms

A 60 cuadros por segundo, cada frame tiene 1000 ÷ 60 = 16.7 ms. Ahí cabe todo: tu JavaScript, el estilo, el layout y el pintado. web.dev recomienda que tu trabajo quepa en unos 10 ms, porque el navegador también necesita su parte. Estas son las técnicas que más rinden:

1. Carga diferida

Three.js no tiene por qué bajar antes de que alguien llegue a la figura. Carga el módulo cuando el contenedor está por entrar en pantalla, con IntersectionObserver:

const caja = document.querySelector('#escena-3d');
const io = new IntersectionObserver(async ([e]) => {
  if (!e.isIntersecting) return;
  io.disconnect();
  const { iniciar } = await import('/js/escena-3d.js');
  iniciar(caja);
}, { rootMargin: '200px' });
io.observe(caja);

2. Pausa fuera de pantalla y en pestaña oculta

Si nadie ve la figura, no la dibujes. Según MDN, la mayoría de los navegadores ya pausa requestAnimationFrame en pestañas en segundo plano, pero no hace nada por una figura que quedó arriba mientras lees el footer. Junta las dos señales, con la Page Visibility API:

let visible = false, raf = 0;

new IntersectionObserver(([e]) => {
  visible = e.isIntersecting;
  actualizar();
}).observe(caja);

document.addEventListener('visibilitychange', actualizar);

function actualizar() {
  const debeCorrer = visible && !document.hidden;
  if (debeCorrer && !raf) raf = requestAnimationFrame(loop);
  if (!debeCorrer && raf) { cancelAnimationFrame(raf); raf = 0; }
}

function loop(t) {
  dibujarFrame(t);
  raf = requestAnimationFrame(loop);
}

3. DPR limitado

devicePixelRatio dice cuántos pixeles físicos hay por pixel CSS. En un celular moderno puede ser 3, y dibujar a DPR 3 es pintar nueve veces más pixeles que a DPR 1. Pon un techo:

renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

En Conqr fui más lejos: en móvil el motor dibuja a DPR 1.

4. Calidad adaptativa

No todos los equipos son iguales. El motor de Conqr baja solo a una calidad ligera si detecta pocos fps o un equipo modesto. La idea es simple: mide unos segundos al arrancar y, si el promedio no da, baja la resolución o la cantidad de detalle. Mejor una figura un poco menos fina que una página que se traba.

5. Cómo medirlo de verdad

Mide el JavaScript de cada frame con performance.now():

let suma = 0, n = 0;
function dibujarFrame(t) {
  const t0 = performance.now();
  actualizarFisica(t);
  render();
  suma += performance.now() - t0;
  if (++n === 180) {
    console.log((suma / n).toFixed(2), 'ms de JS por frame');
    suma = n = 0;
  }
}

Esto mide lo que le cuesta a la CPU preparar y enviar el frame, no lo que tarda la GPU en pintarlo. En Conqr medí lo mismo con el medidor del propio motor, usando Playwright sobre la Home en producción y con el cursor en movimiento (mediana de 4 ventanas de 3 segundos):

EscenarioJS por frameVistas activas
Escritorio 1440×9001.75 ms5
Escritorio 1440×900, CPU 4× más lenta8.62 ms5
Móvil 390×844, CPU 4× más lenta3.77 ms4

Todo cabe en los 16.7 ms, incluso con la CPU frenada cuatro veces.

Accesibilidad: menos movimiento y una imagen fija

Hay personas a las que el movimiento en pantalla les provoca mareo o náuseas. web.dev lo explica bien: el sistema operativo permite pedir menos movimiento, y la página puede leerlo con prefers-reduced-motion. Respétalo antes de cargar nada:

const menosMovimiento = matchMedia('(prefers-reduced-motion: reduce)').matches;
const hayWebGL = !!document.createElement('canvas').getContext('webgl2');

if (menosMovimiento || !hayWebGL) {
  caja.innerHTML = '<img src="/img/figura.webp" alt="Figura cromada" width="800" height="800">';
} else {
  cargarEscena(caja);
}

Dos detalles. Primero, la imagen fija no debería ser una captura cualquiera: en Conqr, cuando no hay WebGL, se muestra una imagen fija que renderizó el propio motor, así que el respaldo se ve como el sitio. Segundo, dale width y height para que no mueva el layout al aparecer.

Tres errores reales (para que no los repitas)

1. Ajustar a ojo

Las primeras versiones del ciclo de formas las ajusté a ojo, contra el video. Se veían bien. Luego medí: tomé 40 frames reales, uno cada 0.25 segundos, y comparé la silueta con IoU, que mide cuánto se enciman dos formas. La meta era IoU ≥ 0.90. Antes del ajuste, solo 29 de 40 frames pasaban, con una media de 0.919 y un mínimo de 0.836. Después de medir contorno, escala y centro en cada frame, pasaron los 40, con una media de 0.960 y un mínimo de 0.932.

Bonus de humildad: también me puse una meta de SSIM ≥ 0.75 y resultó inalcanzable con esa métrica. El propio video comparado consigo mismo, a 0.25 segundos de distancia, daba 0.32–0.43 en los tramos de transformación. Elegir la métrica correcta es parte de medir.

2. Irse por el camino obvio

Un canvas por vista funciona perfecto en la primera demo, con una sola figura. El problema aparece cuando la marca la quiere en el cursor, el menú y cada hero. Si hubiera seguido por ahí, habría chocado con el límite de contextos del navegador. Piensa la arquitectura para el sitio completo, no para la primera sección.

3. Guardar estado «de la carga»

Este no es del motor, pero muerde a cualquier sitio animado. En el carrusel de Expertise, un script guardaba la opacidad de una luz del fondo al cargar la página, cuando valía 0, y la restauraba al volver. Resultado: al regresar, la luz se quedaba apagada. La solución fue guardar el valor que tiene al llegar y restaurar ese. La regla: ninguna animación ligada al scroll guarda estado de la carga.

Checklist para copiar

  • ¿El 3D es la pieza central y se repite? Si no, usa imagen o video.
  • Nada de medios en base64.
  • Un solo renderer, un solo contexto WebGL y un solo requestAnimationFrame.
  • Carga diferida con IntersectionObserver.
  • Pausa fuera de pantalla y con la pestaña oculta.
  • DPR con techo; en móvil, considera DPR 1.
  • Calidad adaptativa según los fps del equipo.
  • Entornos y reflejos generados en código, sin descargar HDR.
  • JS por frame medido con CPU frenada, dentro de 16.7 ms.
  • fps confirmados en dispositivos reales, no en un servidor sin GPU.
  • prefers-reduced-motion respetado.
  • Imagen fija con medidas si no hay WebGL.
  • El equipo lo controla desde un widget, no desde el código.
  • Cada parecido con el diseño, validado con números.

¿Quieres algo así en tu sitio?

Un motor 3D bien hecho no es un capricho: es la pieza que hace que tu marca se recuerde, sin castigar la velocidad ni dejar fuera a nadie. En Conqr fue parte de un proyecto de 9 días que puedes leer en el caso de Conqr. Y si tu sitio todavía no está listo para algo así, empieza por cómo saber si ya te toca renovar tu web.

Si quieres algo así en tu sitio, escríbeme y lo platicamos.

Preguntas frecuentes

¿Un motor 3D hace lenta mi página web?
No tiene por qué. Si se carga en diferido, se pausa cuando nadie lo ve y limita la resolución, cabe en el presupuesto de 16.7 ms por frame. En Conqr el motor gasta 1.75 ms de JavaScript por frame en escritorio.
¿Cuándo conviene un video en lugar de 3D en vivo?
Cuando la pieza cuenta algo lineal y no necesita reaccionar al usuario. El 3D en vivo conviene cuando la figura es la pieza central de la marca, se repite en varios lugares e invita a jugar.
¿Por qué usar un solo renderer WebGL para varias figuras?
Porque los navegadores limitan cuántos contextos WebGL activos puede tener una página. Con un solo renderer hay un único contexto sin importar cuántas vistas haya, y cada vista conserva su lugar en el diseño.
¿Qué pasa si el visitante no tiene WebGL o prefiere menos movimiento?
Se muestra una imagen fija con medidas declaradas, para no mover el layout. En Conqr, el respaldo sin WebGL es una imagen que renderizó el propio motor.
Samuel Torres Pichardo

Samuel Torres Pichardo
Ingeniero de software y cloud en Ciudad de México, con unos 3 años de experiencia profesional. Construí el sitio de Conqr que cuento aquí.
LinkedIn · GitHub · Contacto

Contacto profesional:

Ciudad de México, México

Disponible para proyectos remotos y presenciales.
Ingeniero en Tecnologías de Computación y Telecomunicaciones

© 2026 Ing. Samuel Torres. Ingeniería aplicada.