Cómo Crear tu Propio Visor de Modelos 3D en formato GLTF para Realidad Aumentada Directamente en el Navegador

Antes de comenzar con el post quiero invitarte a la comunidad de Realidad Aumentada Empezando Desde Cero en Whatsapp: LINK DE LA COMUNIDAD
Esta herramienta es clave para programadores, diseñadores, educadores, desarrolladores y negocios de e-commerce.
La facilidad de poder visualizar tus modelos 3D en formato .glb o gltf en un entorno local o en tu propio servidor es una maravilla que permite ahorrar tiempo a la hora de desarrollar aplicaciones WebAR de Realidad Aumentada.
Decidí programar este 3D viewer para AR con una idea en mente, que se ejecutara directamente en el navegador del usuario y no tuviera que subir un solo byte al servidor.
Mira el resultado de este tutorial en: Visor GLTF con Realidad Aumentada
La Solución: Un Visor 100% Client-Side
La herramienta que desarrolle aborda todos estos puntos utilizando el poder de las tecnologías web modernas:
- Funciona Sin Servidor: Se ejecuta completamente en el navegador del usuario (client-side). Los archivos se abren localmente y nunca salen del equipo del usuario, garantizando total privacidad.
- Soporta .GLB y .ZIP: No solo puedes cargar archivos .glb autocontenidos, sino también archivos .zip que incluyan un .gltf junto con todas sus texturas y dependencias. ¡El visor lo descomprime y lo enlaza todo automáticamente!
- Realidad Aumentada Integrada: Gracias al componente <model-viewer> de Google, los usuarios en dispositivos compatibles pueden proyectar el modelo 3D en su propio espacio con un solo clic.
Tal vez te pueda interesar estos post:
- Creando mi Compilador de Marcadores MindAR para WebAR
- Como poner un video sin fondo en realidad aumentada WebAR
Todo el código está comentado línea por línea paso por paso para su comprensión.
Comencemos por el JavaScript:
<script>
// --- SELECCIÓN DE ELEMENTOS DEL DOM ---
// Guarda en constantes referencias a los elementos HTML que vamos a manipular.
const modelViewer = document.getElementById('ar-viewer');
const fileInput = document.getElementById('file-input');
const placeholderContainer = document.getElementById('placeholder-container');
const loadingOverlay = document.getElementById('loading-overlay');
// 'let' porque su valor cambiará. Guardará la URL del objeto 3D actual para poder liberarla de la memoria después.
let currentObjectUrl;
// --- LÓGICA PRINCIPAL DEL VISOR ---
// Añade un "oyente" al input de archivo. Se ejecutará cada vez que el usuario seleccione un archivo ('change' event).
fileInput.addEventListener('change', async (event) => {
// Obtiene el primer archivo que el usuario seleccionó.
const file = event.target.files[0];
// Si el usuario abre la ventana de archivos pero no selecciona nada, la función termina aquí.
if (!file) return;
// Si ya había un modelo cargado (si currentObjectUrl tiene un valor),
// se revoca su URL para liberar memoria del navegador. Es una buena práctica de optimización.
if (currentObjectUrl) {
URL.revokeObjectURL(currentObjectUrl);
}
// Oculta el mensaje de bienvenida ("Esperando un archivo...").
placeholderContainer.style.display = 'none';
// Obtiene la extensión del archivo (ej: "glb", "zip") en minúsculas para evitar errores de mayúsculas.
const fileExtension = file.name.split('.').pop().toLowerCase();
// --- MANEJO DE ARCHIVOS .GLB ---
// Comprueba si el archivo es un .glb.
if (fileExtension === 'glb') {
// Crea una URL local en el navegador que apunta directamente al archivo .glb.
currentObjectUrl = URL.createObjectURL(file);
// Asigna esa URL al atributo 'src' del visor para que lo cargue.
modelViewer.src = currentObjectUrl;
}
// --- MANEJO DE ARCHIVOS .ZIP ---
// Si no es .glb, comprueba si es un .zip.
else if (fileExtension === 'zip') {
// Muestra la animación de "Procesando archivo...".
loadingOverlay.classList.remove('hidden');
// Usa un bloque try...catch para manejar posibles errores durante el procesamiento del .zip.
try {
// Llama a la función que descomprime y procesa el .zip. 'await' pausa la ejecución hasta que la función termine.
const objectURL = await handleZipFile(file);
// Guarda la URL del modelo procesado.
currentObjectUrl = objectURL;
// Asigna la URL final al visor para que lo cargue.
modelViewer.src = currentObjectUrl;
} catch (error) {
// Si algo sale mal en 'handleZipFile', este bloque se ejecuta.
// Muestra el error en la consola del desarrollador para depuración.
console.error('Error procesando el archivo ZIP:', error);
// Muestra una alerta simple al usuario informando del error.
alert('Error al procesar el archivo ZIP. Asegúrate de que contenga un archivo .gltf o .glb válido y sus texturas.');
// Vuelve a mostrar el mensaje de bienvenida.
placeholderContainer.style.display = 'block';
} finally {
// Este bloque se ejecuta siempre, tanto si hubo éxito como si hubo error.
// Oculta la animación de "Procesando archivo...".
loadingOverlay.classList.add('hidden');
}
}
});
// --- FUNCIÓN ASÍNCRONA PARA PROCESAR ARCHIVOS .ZIP ---
async function handleZipFile(file) {
// Carga el archivo .zip en memoria usando la librería JSZip.
const zip = await JSZip.loadAsync(file);
// Prepara variables para almacenar los archivos de modelo si los encontramos.
let gltfFile = null;
let glbFile = null;
// Recorre cada archivo dentro del .zip.
for (const filename in zip.files) {
const lowerCaseFilename = filename.toLowerCase();
// Si encuentra un archivo .gltf, lo guarda.
if (lowerCaseFilename.endsWith('.gltf')) {
gltfFile = zip.files[filename];
}
// Si encuentra un .glb, lo guarda y termina el bucle ('break') porque tiene prioridad.
if (lowerCaseFilename.endsWith('.glb')) {
glbFile = zip.files[filename];
break;
}
}
// CASO 1: Si se encontró un .glb dentro del .zip.
if (glbFile) {
// Convierte el archivo .glb del zip en un 'Blob' (un objeto tipo archivo).
const blob = await glbFile.async('blob');
// Crea una URL local para ese blob y la devuelve. Este es el camino más rápido.
return URL.createObjectURL(blob);
}
// CASO 2: Si no había .glb pero sí un .gltf.
if (gltfFile) {
// Crea un 'Map' para relacionar los nombres de archivo (ej: 'textura.png') con sus nuevas URLs locales.
const fileMap = new Map();
// Procesa todos los archivos del zip para crearles una URL local.
const promises = Object.keys(zip.files).map(async (filename) => {
const fileEntry = zip.files[filename];
// Se asegura de no procesar carpetas, solo archivos.
if (!fileEntry.dir) {
const blob = await fileEntry.async('blob');
const objectURL = URL.createObjectURL(blob);
fileMap.set(filename, objectURL);
}
});
// Espera a que todos los archivos hayan sido procesados y tengan su URL.
await Promise.all(promises);
// Lee el contenido del archivo .gltf como texto plano.
const gltfText = await gltfFile.async('string');
// Convierte el texto en un objeto JSON para poder modificarlo.
let gltfJson = JSON.parse(gltfText);
// Obtiene la ruta de la carpeta donde está el .gltf, para resolver rutas relativas de texturas.
const gltfPath = getBasePath(gltfFile.name);
// Si el JSON del .gltf tiene una sección de 'images'...
if (gltfJson.images) {
// ...recorre cada imagen.
gltfJson.images.forEach(image => {
// Si la ruta de la imagen ('uri') no es un dato incrustado...
if (image.uri && !image.uri.startsWith('data:')) {
// ...resuelve su ruta completa y la reemplaza con la nueva URL local que creamos antes.
const absoluteUri = resolvePath(gltfPath, image.uri);
if (fileMap.has(absoluteUri)) {
image.uri = fileMap.get(absoluteUri);
}
}
});
}
// Hace lo mismo para los 'buffers' (los archivos .bin que contienen la geometría).
if (gltfJson.buffers) {
gltfJson.buffers.forEach(buffer => {
if (buffer.uri && !buffer.uri.startsWith('data:')) {
const absoluteUri = resolvePath(gltfPath, buffer.uri);
if (fileMap.has(absoluteUri)) {
buffer.uri = fileMap.get(absoluteUri);
}
}
});
}
// Crea un nuevo archivo 'Blob' a partir del objeto JSON que hemos modificado.
const modifiedGltfBlob = new Blob([JSON.stringify(gltfJson)], { type: 'application/json' });
// Crea y devuelve una URL para este nuevo .gltf "virtual" que ahora tiene todas las rutas correctas.
return URL.createObjectURL(modifiedGltfBlob);
}
// CASO 3: Si el .zip no contiene ni .gltf ni .glb, lanza un error que será capturado por el bloque 'catch'.
throw new Error('No se encontró un archivo .gltf o .glb válido en el ZIP.');
}
// --- FUNCIONES AUXILIARES ---
// Obtiene la ruta base de un archivo. Ej: de 'modelos/coche.gltf' devuelve 'modelos/'.
function getBasePath(path) {
const lastSlash = path.lastIndexOf('/');
return lastSlash === -1 ? '' : path.substring(0, lastSlash + 1);
}
// Resuelve una ruta relativa. Ej: une 'modelos/' y 'textura.png' para formar 'modelos/textura.png'.
function resolvePath(base, relative) {
return base + relative;
}
</script>Ahora pasemos al CSS:
<style>
/* Define los estilos para el componente <model-viewer>. */
model-viewer {
width: 100%; /* El visor ocupará el 100% del ancho de su contenedor padre. */
height: 100%; /* El visor ocupará el 100% de la altura de su contenedor padre. */
border-radius: 0.75rem; /* Aplica bordes redondeados al visor. */
background-color: #2D3748; /* Establece un color de fondo oscuro para el área del visor. */
--progress-bar-color: #4A90E2; /* Personaliza el color de la barra de progreso de carga del modelo. */
--progress-bar-height: 4px; /* Personaliza la altura de la barra de progreso. */
}
/* Define los estilos para el 'placeholder' (el mensaje de bienvenida). */
.placeholder {
display: flex; /* Utiliza Flexbox para alinear el contenido fácilmente. */
flex-direction: column; /* Apila los elementos hijos uno encima del otro. */
justify-content: center; /* Centra el contenido verticalmente. */
align-items: center; /* Centra el contenido horizontalmente. */
height: 100%; /* El placeholder ocupa toda la altura de su contenedor. */
color: #A0AEC0; /* Define el color del texto del placeholder. */
text-align: center; /* Centra el texto. */
pointer-events: none; /* Hace que el placeholder no sea clickeable, para no interferir con los controles del visor. */
}
/* Estilos para la capa de carga que aparece al procesar un archivo .zip. */
#loading-overlay {
position: absolute; /* Posiciona la capa sobre el visor. */
top: 0; left: 0; right: 0; bottom: 0; /* Hace que la capa ocupe todo el espacio del visor. */
background-color: rgba(45, 55, 72, 0.8); /* Fondo oscuro semitransparente. */
z-index: 10; /* Asegura que la capa esté por encima de otros elementos. */
}
/* Añade un espacio en la parte inferior del body. */
body {
/* Evita que el pie de página fijo (footer) oculte el contenido al hacer scroll en pantallas pequeñas. */
padding-bottom: 80px;
}
</style>y para terminar miremos la parte del html que es super importante:
<!DOCTYPE html>
<!-- La etiqueta <html> es el elemento raíz de la página. lang="es" define el idioma como español. -->
<html lang="es">
<head>
<!-- <meta charset="UTF-8"> especifica la codificación de caracteres a UTF-8, compatible con la mayoría de los símbolos. -->
<meta charset="UTF-8">
<!-- <meta name="viewport"...> asegura que la página se vea bien en todos los dispositivos (diseño responsivo). -->
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- <title> define el texto que aparece en la pestaña del navegador. -->
<title>Visor GLTF con Realidad Aumentada</title>
<!-- Carga la librería de Tailwind CSS desde una CDN para aplicar estilos de forma rápida. -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Carga el componente <model-viewer> de Google. 'type="module"' es crucial para su funcionamiento. -->
<script type="module" src="https://ajax.googleapis.com/ajax/libs/model-viewer/3.5.0/model-viewer.min.js"></script>
<!-- Carga la librería JSZip, necesaria para leer y descomprimir archivos .zip en el navegador. -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<!-- La etiqueta <style> contiene el código CSS personalizado para la página. -->
</head>
<!-- La etiqueta <body> contiene todo el contenido visible de la página. Las clases son de Tailwind CSS. -->
<body class="bg-gray-900 text-white font-sans antialiased">
<!-- Contenedor principal de la página. Centra el contenido y define la altura mínima. -->
<div class="container mx-auto p-4 md:p-8 flex flex-col items-center h-screen">
<!-- Encabezado de la página. -->
<header class="text-center mb-6">
<!-- Título principal con efecto de degradado. -->
<h1 class="text-3xl md:text-4xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-500">Visor de Modelos 3D para Realidad Aumentada</h1>
<!-- Párrafo de instrucción para el usuario. -->
<p class="text-gray-400 mt-2 max-w-2xl">
<!-- La etiqueta <code> formatea el texto como si fuera código. -->
Carga un archivo <code class="bg-gray-700 text-sm p-1 rounded">.glb</code> o un <code class="bg-gray-700 text-sm p-1 rounded">.zip</code> (con .gltf y texturas) para visualizarlo.
</p>
</header>
<!-- Contenedor del área principal que incluye el botón y el visor. -->
<div class="w-full h-full max-w-5xl max-h-[75vh] flex flex-col gap-4">
<!-- Contenedor para centrar el botón de carga. -->
<div class="flex justify-center">
<!-- La etiqueta <label> es el botón visible. 'for="file-input"' la conecta con el input de abajo. -->
<label for="file-input" class="cursor-pointer bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-6 rounded-lg shadow-lg transition-transform transform hover:scale-105">
<!-- Icono SVG dentro del botón para mejorar el diseño. -->
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline-block mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
Seleccionar Archivo (.glb, .zip)
</label>
<!-- Este es el input real que abre el explorador de archivos. Está oculto ('hidden') y se activa con el label. -->
<!-- 'accept' filtra los archivos que el usuario puede seleccionar, incluyendo tipos MIME para compatibilidad con iOS. -->
<input type="file" id="file-input" accept="model/gltf-binary,.glb,application/zip,.zip" class="hidden">
</div>
<!-- Contenedor del visor. 'relative' es necesario para posicionar la capa de carga encima. -->
<div class="flex-grow w-full h-full relative">
<!-- Componente de Google que renderiza el modelo 3D. -->
<model-viewer id="ar-viewer"
src="" <!-- La ruta del modelo a mostrar. Inicialmente está vacía. -->
alt="Visor de modelo 3D" <!-- Texto alternativo para accesibilidad. -->
ar <!-- Habilita el modo de Realidad Aumentada. -->
ar-modes="webxr scene-viewer quick-look" <!-- Especifica los modos de AR a usar (para Android e iOS). -->
camera-controls <!-- Permite al usuario interactuar con el modelo (rotar, zoom). -->
auto-rotate <!-- Hace que el modelo gire lentamente de forma automática. -->
shadow-intensity="1" <!-- Define la intensidad de la sombra proyectada por el modelo. -->
environment-image="neutral" <!-- Aplica una iluminación neutra y genérica al modelo. -->
exposure="1"> <!-- Ajusta el brillo o exposición del modelo. -->
<!-- Contenedor para el mensaje de bienvenida. -->
<div id="placeholder-container">
<!-- Contenido del mensaje de bienvenida. -->
<div id="placeholder-content" class="placeholder">
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mb-4 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4l2 2h4a2 2 0 012 2v12a4 4 0 01-4 4H7z" />
</svg>
<h2 class="font-semibold text-lg">Esperando un archivo...</h2>
<p class="text-sm">Por favor, selecciona un modelo 3D para comenzar.</p>
</div>
</div>
<!-- Capa de carga que se muestra al procesar un .zip. Inicialmente está oculta ('hidden'). -->
<div id="loading-overlay" class="placeholder hidden">
<!-- Animación de puntos de carga. -->
<div class="flex items-center justify-center space-x-2">
<div class="w-4 h-4 rounded-full animate-pulse bg-blue-400"></div>
<div class="w-4 h-4 rounded-full animate-pulse bg-blue-400" style="animation-delay: 0.2s;"></div>
<div class="w-4 h-4 rounded-full animate-pulse bg-blue-400" style="animation-delay: 0.4s;"></div>
</div>
<p class="mt-4 text-lg">Procesando archivo...</p>
</div>
</model-viewer>
</div>
</div>
</div>
</body>
</html>