Crea tu Visor 3D/AR Web: ¡Fácil y Sin Servidor!

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

un modelo 3d de un auto corvette de color amarillo para carreras
gltf viewer for augmented reality

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:

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>
0 0 votes
Article Rating
Subscribe
Notify of
guest

0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
Scroll al inicio
0
Would love your thoughts, please comment.x
()
x