Realidad Aumentada en tu Navegador: ¡Controla la Iluminación de tus Modelos 3D con A-Frame y MindAR!

¿Alguna vez has querido ver un modelo 3D con realidad aumentada directamente en tu navegador y, además, tener control sobre cómo se ilumina? ¡Con la combinación de A-Frame y MindAR, esto es totalmente posible! En esta entrada, te mostramos un ejemplo práctico de cómo puedes lograrlo y experimentar con la iluminación de tus objetos virtuales.
Toda persona que desarrolla apps de realidad aumentada sabe la importancia de las texturas o iluminación en los modelos 3D que están en nuestra escena AR. Generalmente esto se convierte en un dolor de cabeza, pero aquí te voy a enseñar y mostrar cómo hacer tu propio test de iluminación o como integrar estas funciones dentro de tu aplicación WebAR.
Este código te permite:
- Ver modelos 3D en Realidad Aumentada (AR): Usando MindAR.js, tu cámara web detectará un marcador (previamente definido en targets.mind) y superpondrá un modelo GLTF sobre él.
¿No sabes que es un archivo en formato .mind o que es un marcador? Aqui te dejo este post: La importancia de tu marcador en Realidad Aumentada Web y también te dejo este otro post: https://blog.realidad-aumentada.com.co/generador-de-marcadores-para-realidad-aumentada-online/ - Interactuar con la iluminación: Hemos añadido un panel de control intuitivo que te permite ajustar la posición y la intensidad de tres luces direccionales diferentes (blanca, roja y azul). Esto es crucial para entender cómo la luz afecta la apariencia de tus modelos 3D.
- Sombras realistas: El sistema está configurado para proyectar y recibir sombras, lo que añade un gran nivel de realismo a la experiencia AR.
Aquí va el código:
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="https://aframe.io/releases/1.5.0/aframe.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/mind-ar@1.2.1/dist/mindar-image-aframe.prod.js"></script>
<style>
body {
margin: 0;
overflow: hidden;
}
#controls {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 10px;
z-index: 100;
justify-content: center;
align-items: center;
flex-wrap: wrap;
width: 90%;
}
.light-button {
background-color: rgba(0, 0, 0, 0.6);
color: white;
border: 2px solid white;
padding: 10px 15px;
font-size: 16px;
cursor: pointer;
border-radius: 5px;
transition: background-color 0.3s, border-color 0.3s;
flex-shrink: 0;
}
.light-button:hover {
background-color: rgba(0, 0, 0, 0.8);
border-color: #00bcd4;
}
#directional-slider-container {
position: absolute;
bottom: 100px;
left: 50%;
transform: translateX(-50%);
display: flex; /* Oculto por defecto en JS */
flex-direction: column;
align-items: center;
gap: 10px;
z-index: 100;
/* Fondo casi transparente */
background-color: rgba(0, 0, 0, 0.2); /* Ajustado a 0.2 para ser más transparente */
padding: 10px 15px;
border-radius: 8px;
color: white;
width: 80%;
max-width: 400px;
}
#directional-slider-container h3 {
margin-top: 0;
margin-bottom: 5px;
text-align: center;
}
#directional-slider-container label {
font-size: 14px;
}
.slider-group {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
margin-bottom: 8px; /* Espacio entre grupos de sliders */
}
.slider-group input {
width: 100%;
}
/* El div #model-debug ha sido ELIMINADO del CSS */
</style>
</head>
<body>
<div id="controls">
<button class="light-button" onclick="toggleDirectionalControls()">Control de Luces Direccionales</button>
</div>
<div id="directional-slider-container">
<h3>Control de Luz Direccional (Blanca en X, Roja en Y, Azul en Z)</h3>
<div class="slider-group">
<label for="directional-x-pos">Luz X Posición: <span id="directional-x-pos-val">2.0</span></label>
<input type="range" id="directional-x-pos" min="-5" max="5" value="2" step="0.1">
</div>
<div class="slider-group">
<label for="directional-intensity-x">Luz X Intensidad: <span id="directional-intensity-x-val">3.0</span></label>
<input type="range" id="directional-intensity-x" min="0" max="10" value="3" step="0.1">
</div>
<div class="slider-group">
<label for="directional-y-pos">Luz Y Posición: <span id="directional-y-pos-val">5.0</span></label>
<input type="range" id="directional-y-pos" min="0.1" max="10" value="5" step="0.1">
</div>
<div class="slider-group">
<label for="directional-intensity-y">Luz Y Intensidad: <span id="directional-intensity-y-val">3.0</span></label>
<input type="range" id="directional-intensity-y" min="0" max="10" value="3" step="0.1">
</div>
<div class="slider-group">
<label for="directional-z-pos">Luz Z Posición: <span id="directional-z-pos-val">-3.0</span></label>
<input type="range" id="directional-z-pos" min="-5" max="5" value="-3" step="0.1"> </div>
<div class="slider-group">
<label for="directional-intensity-z">Luz Z Intensidad: <span id="directional-intensity-z-val">3.0</span></label>
<input type="range" id="directional-intensity-z" min="0" max="10" value="3" step="0.1">
</div>
</div>
<a-scene
mindar-image="imageTargetSrc: targets.mind; maxTrack: 1; uiLoading: yes; uiError: yes; uiScanning: yes;"
vr-mode-ui="enabled: false"
device-orientation-permission-ui="enabled: false"
shadow="enabled: true; type: pcfsoft" renderer="colorManagement: true" >
<a-camera
position="0 0 0"
look-controls="enabled: false"
></a-camera>
<a-entity mindar-image-target="targetIndex: 0" id="my-marker">
<a-gltf-model
src="result.gltf"
scale="5 5 5" position="0 0 0" rotation="270 -180 0" id="my-gltf-model"
shadow="receive: true; cast: true;" ></a-gltf-model>
<a-entity id="ar-lights">
<a-entity id="directional-light-x" light="type: directional; intensity: 3.0; color: #FFFFFF; castShadow: true; shadowMapWidth: 2048; shadowMapHeight: 2048; shadowCameraFar: 15; shadowCameraLeft: -5; shadowCameraRight: 5; shadowCameraTop: 5; shadowCameraBottom: -5;" position="2 3 -3"></a-entity>
<a-entity id="directional-light-y" light="type: directional; intensity: 3.0; color: #FF0000; castShadow: true; shadowMapWidth: 2048; shadowMapHeight: 2048; shadowCameraFar: 15; shadowCameraLeft: -5; shadowCameraRight: 5; shadowCameraTop: 5; shadowCameraBottom: -5;" position="0 5 -3"></a-entity>
<a-entity id="directional-light-z" light="type: directional; intensity: 3.0; color: #0000FF; castShadow: true; shadowMapWidth: 2048; shadowMapHeight: 2048; shadowCameraFar: 15; shadowCameraLeft: -5; shadowCameraRight: 5; shadowCameraTop: 5; shadowCameraBottom: -5;" position="0 3 -3"></a-entity>
<a-entity id="global-ambient-light" light="type: ambient; intensity: 0.1; color: #FFF"></a-entity>
</a-entity>
</a-entity>
<a-entity light-controller></a-entity>
</a-scene>
<script>
let directionalControlsVisible = false;
AFRAME.registerComponent('light-controller', {
init: function () {
this.lightContainer = document.querySelector('#ar-lights');
this.model = document.querySelector('#my-gltf-model');
this.sceneEl = this.el.sceneEl;
this.directionalLightX = document.querySelector('#directional-light-x');
this.directionalLightY = document.querySelector('#directional-light-y');
this.directionalLightZ = document.querySelector('#directional-light-z');
this.globalAmbientLight = document.querySelector('#global-ambient-light');
this.directionalSliderContainer = document.getElementById('directional-slider-container');
// --- Sliders originales (una posición por luz) ---
this.directionalXPosSlider = document.getElementById('directional-x-pos');
this.directionalYPosSlider = document.getElementById('directional-y-pos');
this.directionalZPosSlider = document.getElementById('directional-z-pos');
this.directionalIntensityXSlider = document.getElementById('directional-intensity-x');
this.directionalIntensityYSlider = document.getElementById('directional-intensity-y');
this.directionalIntensityZSlider = document.getElementById('directional-intensity-z');
// --- Display de valores para los sliders originales ---
this.directionalXPosValDisplay = document.getElementById('directional-x-pos-val');
this.directionalYPosValDisplay = document.getElementById('directional-y-pos-val');
this.directionalZPosValDisplay = document.getElementById('directional-z-pos-val');
this.directionalIntensityXValDisplay = document.getElementById('directional-intensity-x-val');
this.directionalIntensityYValDisplay = document.getElementById('directional-intensity-y-val');
this.directionalIntensityZValDisplay = document.getElementById('directional-intensity-z-val');
// Almacenar los valores iniciales de los sliders (una posición por luz)
this.lightStates = {
directional: {
posX: parseFloat(this.directionalXPosSlider.value),
posY: parseFloat(this.directionalYPosSlider.value),
posZ: parseFloat(this.directionalZPosSlider.value),
intensityX: parseFloat(this.directionalIntensityXSlider.value),
intensityY: parseFloat(this.directionalIntensityYSlider.value),
intensityZ: parseFloat(this.directionalIntensityZSlider.value),
},
};
this.sceneEl.addEventListener('loaded', () => {
console.log("Escena cargada. Habilitando shadowMap en el renderer de Three.js.");
this.sceneEl.renderer.shadowMap.enabled = true;
this.sceneEl.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
console.log("renderer.shadowMap.enabled:", this.sceneEl.renderer.shadowMap.enabled);
});
this.model.addEventListener('model-loaded', () => {
// Se eliminó la actualización de los elementos de depuración en pantalla
console.log("Modelo GLTF cargado. Verificando propiedades de sombra...");
const modelObject = this.model.getObject3D('mesh');
if (modelObject) {
let materialTypes = new Set();
modelObject.traverse((node) => {
if (node.isMesh) {
node.castShadow = true;
node.receiveShadow = true;
if (node.material) {
const materials = Array.isArray(node.material) ? node.material : [node.material];
materials.forEach(mat => {
materialTypes.add(mat.type);
if (mat.type === 'MeshStandardMaterial' || mat.type === 'MeshPhysicalMaterial') {
if (mat.emissive && mat.emissive.getHex() !== 0x000000) {
console.warn(`[DEBUG] Material ${mat.name} tenía emissive. Forzando a negro.`);
mat.emissive.setHex(0x000000);
}
if (mat.emissiveIntensity && mat.emissiveIntensity > 0) {
console.warn(`[DEBUG] Material ${mat.name} tenía emissiveIntensity. Forzando a 0.`);
mat.emissiveIntensity = 0;
}
mat.needsUpdate = true;
} else if (mat.type === 'MeshBasicMaterial') {
console.error(`¡ADVERTENCIA CRÍTICA! Material ${mat.name} es MeshBasicMaterial. No reaccionará a las luces realistas ni proyectará sombras.`);
}
});
}
}
});
console.log("Tipos de materiales encontrados (para depuración interna):", Array.from(materialTypes).join(', '));
} else {
console.warn("No se pudo obtener el objeto 3D 'mesh' del modelo GLTF.");
}
});
this.updateDirectionalLightProperties = () => {
const state = this.lightStates.directional;
this.directionalLightX.setAttribute('position', `${state.posX} 3 -3`);
this.directionalLightY.setAttribute('position', `0 ${state.posY} -3`);
this.directionalLightZ.setAttribute('position', `0 3 ${state.posZ}`);
this.directionalLightX.setAttribute('light', 'intensity', state.intensityX);
this.directionalLightY.setAttribute('light', 'intensity', state.intensityY);
this.directionalLightZ.setAttribute('light', 'intensity', state.intensityZ);
this.directionalXPosValDisplay.textContent = state.posX.toFixed(1);
this.directionalYPosValDisplay.textContent = state.posY.toFixed(1);
this.directionalZPosValDisplay.textContent = state.posZ.toFixed(1);
this.directionalIntensityXValDisplay.textContent = state.intensityX.toFixed(1);
this.directionalIntensityYValDisplay.textContent = state.intensityY.toFixed(1);
this.directionalIntensityZValDisplay.textContent = state.intensityZ.toFixed(1);
};
// --- Event Listeners para los sliders originales ---
this.directionalXPosSlider.addEventListener('input', () => {
this.lightStates.directional.posX = parseFloat(this.directionalXPosSlider.value);
this.updateDirectionalLightProperties();
});
this.directionalYPosSlider.addEventListener('input', () => {
this.lightStates.directional.posY = parseFloat(this.directionalYPosSlider.value);
this.updateDirectionalLightProperties();
});
this.directionalZPosSlider.addEventListener('input', () => {
this.lightStates.directional.posZ = parseFloat(this.directionalZPosSlider.value);
this.updateDirectionalLightProperties();
});
this.directionalIntensityXSlider.addEventListener('input', () => {
this.lightStates.directional.intensityX = parseFloat(this.directionalIntensityXSlider.value);
this.updateDirectionalLightProperties();
});
this.directionalIntensityYSlider.addEventListener('input', () => {
this.lightStates.directional.intensityY = parseFloat(this.directionalIntensityYSlider.value);
this.updateDirectionalLightProperties();
});
this.directionalIntensityZSlider.addEventListener('input', () => {
this.lightStates.directional.intensityZ = parseFloat(this.directionalIntensityZSlider.value);
this.updateDirectionalLightProperties();
});
window.toggleDirectionalControls = () => {
directionalControlsVisible = !directionalControlsVisible;
if (directionalControlsVisible) {
this.directionalSliderContainer.style.display = 'flex';
this.updateDirectionalLightProperties();
} else {
this.directionalSliderContainer.style.display = 'none';
}
};
// Inicializar: Ocultar el panel al cargar para que el botón lo "despliegue"
this.directionalSliderContainer.style.display = 'none';
directionalControlsVisible = false; // Asegurarse de que el estado inicial sea oculto
this.updateDirectionalLightProperties(); // Asegurarse de que las luces tienen sus valores iniciales
}
});
</script>
</body>
</html>
Te explico lo más importante del código:
Aunque el HTML define la estructura de la escena y la interfaz de usuario, la verdadera «inteligencia» reside en el código JavaScript, específicamente en un componente personalizado de A-Frame llamado light-controller.
Aquí te desglosamos lo que hace el javascript:
Conexión entre la Interfaz y el 3D:
- Al inicio, el código «encuentra» todos los elementos clave en tu HTML: los sliders para mover las luces y cambiar su intensidad, los displays de texto que muestran los valores actuales de esos sliders, y las luces 3D y el modelo 3D dentro de la escena de A-Frame.
- Mantiene un registro de los valores actuales de cada slider en una variable (this.lightStates), para saber siempre la posición e intensidad deseada de cada luz.
Activación de Sombras:
Una vez que la escena 3D se carga, el JavaScript se asegura de que el motor de renderizado (Three.js, la base de A-Frame) tenga las sombras activadas. Esto es fundamental para que tus modelos 3D puedan proyectar y recibir sombras realistas, haciendo que la luz se sienta mucho más creíble.
Preparación del Modelo 3D para la Sombra:
Cuando tu modelo 3D (el archivo .gltf) termina de cargarse, el código lo examina. Se asegura de que cada parte del modelo (cada «malla» o mesh) esté configurada correctamente para proyectar y recibir sombras. También incluye algunas comprobaciones internas para avisarte si los materiales de tu modelo no son los más adecuados para una iluminación realista (por ejemplo, si no responden bien a las luces).
Actualización Dinámica de las Luces:
- La función principal, updateDirectionalLightProperties, es el corazón de la interactividad. Cada vez que mueves uno de los sliders en tu interfaz, esta función se activa.
- Toma el nuevo valor del slider (por ejemplo, la posición X de la luz blanca) y lo aplica inmediatamente a la luz 3D correspondiente en la escena de A-Frame.
- Al mismo tiempo, actualiza el número que ves junto al slider en la interfaz para que coincida con el valor actual.
Control de Visibilidad de la Interfaz:
Finalmente, hay una función (toggleDirectionalControls) que es llamada por el botón «Control de Luces Direccionales». Su único propósito es mostrar u ocultar el panel de sliders, limpiando la pantalla cuando no necesitas ajustar la iluminación.
¿Listo para Iluminar tus apps de realidad aumentada con este tutorial?
Con este conocimiento básico sobre las herramientas y cómo el código opera, estás un paso más cerca de crear tus propias experiencias de Realidad Aumentada interactivas. ¡La posibilidad de controlar la luz en tiempo real es una característica poderosa para dar vida a tus modelos 3D y explorarlos de nuevas maneras!
Aquí te dejo por si quieres saber más acerca de la iluminación en:
- A-Frame: Documentacion sobre luces
- Three.js: Documentacion
Este es el resultado de la aplicacion.