Cómo Creamos una Experiencia WebAR de Ghostbusters (Cazafantasmas) con una Camiseta (T-Shirt) para Halloween

En este tutorial te muestro el camino para que hagas tus propias camisetas o tu disfraz. Ā”Ćsalo como guĆa y empieza a crear!
ĀæQuieres hacer aplicaciones de realidad aumentada en minutos y gratis?
visita la Herramienta de Realidad Aumentada Empezando desde cero: Editor AR Online: Modelos 3D (GLB, GLTF) y Video (MP4)
Ā”Se acerca Halloween! š Y quĆ© mejor manera de celebrarlo que llevando la Realidad Aumentada (WebAR) a otro nivel. En este proyecto, hemos creado una experiencia completa de los Cazafantasmas que se activa usando una simple camiseta como marcador.
La idea es la siguiente: el usuario visita una pÔgina web desde su móvil, apunta la cÔmara a una camiseta con el logo de Ghostbusters en el pecho, ”y un video aparece sobre ella! Si le da la vuelta, el diseño de la espalda (Pegajoso -Slimer o ECTO-1) activa un modelo 3D o un segundo video.
Para lograr esta experiencia inmersiva, no hemos creado una, sino dos pÔginas web que trabajan juntas: una espectacular pantalla de bienvenida (index.html) y la aplicación de Realidad Aumentada en sà (RA.html). Ambas estÔn configuradas como una Aplicación Web Progresiva (PWA), lo que permite a los usuarios «instalarla» en su pantalla de inicio para que se sienta como una app nativa.
Las imƔgenes que vamos a utilizar como marcadores (.mind) serƔn:

Para hacer los archivos .mind que utiliza MindAR ingresamos al siguiente link y subimos las tres imƔgenes al tiempo: Compilador de Marcadores AR | Crea Realidad Aumentada Gratis
Luego generamos y descargamos el archivo .mind y lo guardamos en la carpeta de nuestro proyecto.
Continuemos. Vamos a desglosar el código de cada una de las pÔginas.
Parte 1: index.html ā La Bienvenida Inmersiva con WebGL
La primera impresión lo es todo. QuerĆamos que, antes de cazar fantasmas, el usuario sintiera que estaba entrando en un mundo misterioso. Para eso, creamos un splash screen interactivo.
El archivo index.html no es la aplicación de RA; es su antesala.
CaracterĆsticas Clave del index.html:
- Efecto de Humo Interactivo (WebGL): La estrella de esta pÔgina es el efecto de humo dinÔmico. No es un video, es un <canvas> de WebGL que renderiza en tiempo real un fluido de humo que reacciona al movimiento del ratón (en escritorio) o al tacto (en móviles). Esto consume recursos, pero solo se usa en esta pÔgina de bienvenida.
- Animación de TĆtulo: El tĆtulo Ā«GHOSTBUSTERS ARĀ» no aparece de golpe. Usamos un script que divide la palabra en letras (<span class=Ā»letter-0″>…</span>) y las anima para que aparezcan progresivamente, dĆ”ndole un toque cinematogrĆ”fico.
- Botón de Inicio Ā«SlimerĀ»: El botón Ā«INICIARĀ» tiene un caracterĆstico color verde neón (#00ff00) con un efecto box-shadow que lo hace brillar. Su Ćŗnica función es clara: al hacer clic, redirige al usuario a la experiencia principal con window.location.href = ‘RA.html’.
- Fondo y Estilo: Un fondo estÔtico (intro.jpg) y fuentes temÔticas (Creepster, Cinzel) establecen la atmósfera de Halloween.
- En resumen, index.html actúa como un portal espectacular que genera expectativa antes de iniciar la cÔmara.
Te dejo el video de como se ve index.html:
Parte 2: RA.html ā El NĆŗcleo de la Realidad Aumentada
Aquà es donde ocurre la magia. Esta pÔgina carga A-Frame y MindAR para gestionar la cÔmara y el reconocimiento de imÔgenes. EstÔ diseñada para ser la aplicación principal.
CaracterĆsticas Clave del RA.html:
- Pantalla de Carga Personalizada: Hemos desactivado la pantalla de carga por defecto de A-Frame (loading-screen=Ā»enabled: falseĀ») y hemos creado la nuestra. Es la barra de progreso verde neón (Ā«spookyĀ») que vimos en el CSS. El script de JavaScript escucha el evento arScene.addEventListener(‘progress’, …), calcula el porcentaje de carga de los assets (videos, modelos 3D) y actualiza la barra (loaderBar.style.width).
La Escena y los Marcadores (La Camiseta):
- La <a-scene> tiene el componente mindar-image que apunta a nuestro archivo Marker.mind.
- Tenemos 3 assets principales: dos videos (video-ghostbusters, video-1) y un modelo 3D (protones-model descargado de la comunidad: Sketchfab).
- targetIndex: 0 (el logo frontal de la camiseta) muestra el video GhostBusters.mp4.
- targetIndex: 1 (el diseƱo trasero) muestra el video 1.webm.
- targetIndex: 2 (un posible tercer marcador) muestra el modelo 3D protones.glb.
Interfaz de «Visor»: Para que se sienta como un dispositivo de los Cazafantasmas, hemos añadido elementos de HTML/CSS fijos sobre la vista de la cÔmara:
- Cuatro imƔgenes en las esquinas (.corner-image).
- Un logo en la parte inferior (#logo-inferior).
- Estos elementos z-index estƔn por encima de la escena de A-Frame, pero por debajo de los controles.
Lógica de JavaScript (Autoplay y Audio):
- Autoplay al Detectar: Los videos estÔn configurados con muted en el HTML para permitir el autoplay en móviles. Cuando un marcador es detectado (targetFound), el script ejecuta video0.play() o video1.play() inmediatamente. Al perderse (targetLost), se pausan.
- Un Botón para todo: Solo hay un botón de control: «SONIDO».
- Audio Sincronizado: Este botón (mute-unmute-button) controla el audio de ambos videos al mismo tiempo. Si el usuario activa el sonido, lo activa para cualquier video que esté viendo.
- Botón Inteligente: El botón no es visible todo el tiempo. Usamos updateControlsVisibility() para que solo aparezca si target0 O target1 estÔn visibles. Si el usuario no estÔ apuntando a ningún marcador de video, la interfaz estÔ limpia.
Bonus: La Experiencia PWA
- Ambos archivos HTML incluyen las cabeceras <meta> y el enlace al manifest.json. Esto convierte la web en una Aplicación Web Progresiva (PWA).
- apple-mobile-web-app-capable: Le dice a iOS que, si el usuario la «Añade a la pantalla de inicio», se abra en pantalla completa.
- display: «standalone» (en el manifest.json): Hace lo mismo para Android.
- El resultado es que el usuario puede tener un icono de Ā«Ghostbusters ARĀ» en su móvil que abre la experiencia directamente, sin la barra de direcciones del navegador, ofreciendo una inmersión total. š»
Pasemos al código del HTML inicial (index.html).
<!DOCTYPE html><html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Ghostbusters AR - Inicio</title>
<link rel="icon" type="image/png" sizes="32x32" href="./Iconos/32x32.png">
<meta name="theme-color" content="#000000">
<link rel="manifest" href="./manifest.json">
<link rel="apple-touch-icon" href="./Iconos/apple-touch-icon-180x180.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Creepster&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Lato&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Cinzel&display=swap" rel="stylesheet">
<style>
html, body {
margin: 0;
padding: 0;
width: 100vw;
height: 100vh;
overflow: hidden;
position: fixed;
overscroll-behavior: none;
background-image: url('./intro.jpg');
background-size: cover;
background-position: center center;
background-repeat: no-repeat;
background-color: #000;
font-family: 'Lato', sans-serif;
color: #fefeff;
}
#splash-screen {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 100;
overflow: hidden;
}
#splash-screen canvas#smoke-canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 101;
}
#splash-screen h1#smoke-h1 {
white-space: nowrap;
font-size: clamp(1.8em, 8vw, 3em);
letter-spacing: clamp(0.15em, 1.5vw, 0.35em);
font-family: 'Cinzel', serif;
font-weight: 400;
visibility: hidden;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 102;
margin: 0;
color: #000;
}
#splash-screen h1#smoke-h1.transition-in {
visibility: visible;
}
#splash-screen h1#smoke-h1 [class^="letter"] {
opacity: 0;
-webkit-transition: opacity 3s ease;
-moz-transition: opacity 3s ease;
transition: opacity 3s ease;
}
#splash-screen h1#smoke-h1.transition-in [class^="letter"] {
opacity: 1;
}
#splash-screen .letter-0 { transition-delay: 0.2s; }
#splash-screen .letter-1 { transition-delay: 0.4s; }
#splash-screen .letter-2 { transition-delay: 0.6s; }
#splash-screen .letter-3 { transition-delay: 0.8s; }
#splash-screen .letter-4 { transition-delay: 1.0s; }
#splash-screen .letter-5 { transition-delay: 1.2s; }
#splash-screen .letter-6 { transition-delay: 1.4s; }
#splash-screen .letter-7 { transition-delay: 1.6s; }
#splash-screen .letter-8 { transition-delay: 1.8s; }
#splash-screen .letter-9 { transition-delay: 2.0s; }
#splash-screen .letter-10 { transition-delay: 2.2s; }
#splash-screen .letter-11 { transition-delay: 2.4s; }
#splash-screen .letter-12 { transition-delay: 2.6s; }
#splash-screen .letter-13 { transition-delay: 2.8s; }
#splash-screen .letter-14 { transition-delay: 3.0s; }
#splash-screen .letter-15 { transition-delay: 3.2s; }
#splash-screen #start-button {
position: absolute;
bottom: 15vh;
left: 50%;
transform: translateX(-50%);
margin-top: 0;
font-size: clamp(1.2rem, 5vw, 1.5rem);
z-index: 103;
padding: 15px 30px;
font-family: 'Creepster', cursive;
color: white;
background-color: #00ff00;
border: 3px solid white;
border-radius: 10px;
cursor: pointer;
text-shadow: 2px 2px 5px black;
box-shadow: 0 0 15px #00ff00, 0 0 25px #00ff00;
}
</style>
</head>
<body>
<div id="splash-screen">
<canvas id="smoke-canvas"></canvas>
<h1 id="smoke-h1">GHOSTBUSTERS AR</h1>
<button id="start-button">INICIAR</button>
</div>
<script>
'use strict';
document.addEventListener('DOMContentLoaded', () => {
const startButton = document.getElementById('start-button');
startButton.addEventListener('click', () => {
window.location.href = 'RA.html';
});
(function() {
var canvas = document.getElementById('smoke-canvas');
if (!canvas) {
console.error("No se encontró el canvas #smoke-canvas");
return;
}
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
var config = {
TEXTURE_DOWNSAMPLE: 1,
DENSITY_DISSIPATION: 0.98,
VELOCITY_DISSIPATION: 0.99,
PRESSURE_DISSIPATION: 0.8,
PRESSURE_ITERATIONS: 25,
CURL: 30,
SPLAT_RADIUS: 0.005
};
var pointers = [];
var splatStack = [];
pointers.push(new pointerPrototype());
var _getWebGLContext = getWebGLContext(canvas);
var gl = _getWebGLContext.gl;
var ext = _getWebGLContext.ext;
var support_linear_float = _getWebGLContext.support_linear_float;
if (!gl) {
console.error("No se pudo inicializar WebGL. El efecto de humo no funcionarĆ”.");
return;
}
function getWebGLContext(canvas) {
var params = { alpha: true, premultipliedAlpha: true, depth: false, stencil: false, antialias: false };
var gl = canvas.getContext('webgl2', params);
var isWebGL2 = !!gl;
if (!isWebGL2) gl = canvas.getContext('webgl', params) || canvas.getContext('experimental-webgl', params);
if (!gl) return { gl: null };
var halfFloat = gl.getExtension('OES_texture_half_float');
var support_linear_float = gl.getExtension('OES_texture_half_float_linear');
if (isWebGL2) {
gl.getExtension('EXT_color_buffer_float');
support_linear_float = gl.getExtension('OES_texture_float_linear');
}
gl.clearColor(0.0, 0.0, 0.0, 0.0);
var internalFormat = isWebGL2 ? gl.RGBA16F : gl.RGBA;
var internalFormatRG = isWebGL2 ? gl.RG16F : gl.RGBA;
var formatRG = isWebGL2 ? gl.RG : gl.RGBA;
var texType = isWebGL2 ? gl.HALF_FLOAT : halfFloat.HALF_FLOAT_OES;
return {
gl: gl,
ext: { internalFormat: internalFormat, internalFormatRG: internalFormatRG, formatRG: formatRG, texType: texType },
support_linear_float: support_linear_float
};
}
function pointerPrototype() {
this.id = -1; this.x = 0; this.y = 0; this.dx = 0; this.dy = 0;
this.down = false; this.moved = false; this.color = [30, 0, 300];
}
var GLProgram = function () {
function GLProgram(vertexShader, fragmentShader) {
if (!(this instanceof GLProgram)) throw new TypeError("Cannot call a class as a function");
this.uniforms = {};
this.program = gl.createProgram();
gl.attachShader(this.program, vertexShader);
gl.attachShader(this.program, fragmentShader);
gl.linkProgram(this.program);
if (!gl.getProgramParameter(this.program, gl.LINK_STATUS)) throw gl.getProgramInfoLog(this.program);
var uniformCount = gl.getProgramParameter(this.program, gl.ACTIVE_UNIFORMS);
for (var i = 0; i < uniformCount; i++) {
var uniformName = gl.getActiveUniform(this.program, i).name;
this.uniforms[uniformName] = gl.getUniformLocation(this.program, uniformName);
}
}
GLProgram.prototype.bind = function bind() { gl.useProgram(this.program); };
return GLProgram;
}();
function compileShader(type, source) {
var shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) throw gl.getShaderInfoLog(shader);
return shader;
}
var baseVertexShader = compileShader(gl.VERTEX_SHADER, 'precision highp float;precision mediump sampler2D;attribute vec2 aPosition;varying vec2 vUv;varying vec2 vL;varying vec2 vR;varying vec2 vT;varying vec2 vB;uniform vec2 texelSize;void main(){vUv=aPosition*0.5+0.5;vL=vUv-vec2(texelSize.x,0.0);vR=vUv+vec2(texelSize.x,0.0);vT=vUv+vec2(0.0,texelSize.y);vB=vUv-vec2(0.0,texelSize.y);gl_Position=vec4(aPosition,0.0,1.0);}');
var clearShader = compileShader(gl.FRAGMENT_SHADER, 'precision highp float;precision mediump sampler2D;varying vec2 vUv;uniform sampler2D uTexture;uniform float value;void main(){gl_FragColor=value*texture2D(uTexture,vUv);}');
var displayShader = compileShader(gl.FRAGMENT_SHADER, 'precision highp float;precision mediump sampler2D;varying vec2 vUv;uniform sampler2D uTexture;void main(){vec4 smoke = texture2D(uTexture,vUv); float intensity = (smoke.r + smoke.g + smoke.b) * 0.333; gl_FragColor = vec4(smoke.rgb, intensity);}');
var splatShader = compileShader(gl.FRAGMENT_SHADER, 'precision highp float;precision mediump sampler2D;varying vec2 vUv;uniform sampler2D uTarget;uniform float aspectRatio;uniform vec3 color;uniform vec2 point;uniform float radius;void main(){vec2 p=vUv-point.xy;p.x*=aspectRatio;vec3 splat=exp(-dot(p,p)/radius)*color;vec3 base=texture2D(uTarget,vUv).xyz;gl_FragColor=vec4(base+splat,1.0);}');
var advectionManualFilteringShader = compileShader(gl.FRAGMENT_SHADER, 'precision highp float;precision mediump sampler2D;varying vec2 vUv;uniform sampler2D uVelocity;uniform sampler2D uSource;uniform vec2 texelSize;uniform float dt;uniform float dissipation;vec4 bilerp(in sampler2D sam,in vec2 p){vec4 st;st.xy=floor(p-0.5)+0.5;st.zw=st.xy+1.0;vec4 uv=st*texelSize.xyxy;vec4 a=texture2D(sam,uv.xy);vec4 b=texture2D(sam,uv.zy);vec4 c=texture2D(sam,uv.xw);vec4 d=texture2D(sam,uv.zw);vec2 f=p-st.xy;return mix(mix(a,b,f.x),mix(c,d,f.x),f.y);}void main(){vec2 coord=gl_FragCoord.xy-dt*texture2D(uVelocity,vUv).xy;gl_FragColor=dissipation*bilerp(uSource,coord);gl_FragColor.a=1.0;}');
var advectionShader = compileShader(gl.FRAGMENT_SHADER, 'precision highp float;precision mediump sampler2D;varying vec2 vUv;uniform sampler2D uVelocity;uniform sampler2D uSource;uniform vec2 texelSize;uniform float dt;uniform float dissipation;void main(){vec2 coord=vUv-dt*texture2D(uVelocity,vUv).xy*texelSize;gl_FragColor=dissipation*texture2D(uSource,coord);}');
var divergenceShader = compileShader(gl.FRAGMENT_SHADER, 'precision highp float;precision mediump sampler2D;varying vec2 vUv;varying vec2 vL;varying vec2 vR;varying vec2 vT;varying vec2 vB;uniform sampler2D uVelocity;vec2 sampleVelocity(in vec2 uv){vec2 multiplier=vec2(1.0,1.0);if(uv.x<0.0){uv.x=0.0;multiplier.x=-1.0;}if(uv.x>1.0){uv.x=1.0;multiplier.x=-1.0;}if(uv.y<0.0){uv.y=0.0;multiplier.y=-1.0;}if(uv.y>1.0){uv.y=1.0;multiplier.y=-1.0;}return multiplier*texture2D(uVelocity,uv).xy;}void main(){float L=sampleVelocity(vL).x;float R=sampleVelocity(vR).x;float T=sampleVelocity(vT).y;float B=sampleVelocity(vB).y;float div=0.5*(R-L+T-B);gl_FragColor=vec4(div,0.0,0.0,1.0);}');
var curlShader = compileShader(gl.FRAGMENT_SHADER, 'precision highp float;precision mediump sampler2D;varying vec2 vUv;varying vec2 vL;varying vec2 vR;varying vec2 vT;varying vec2 vB;uniform sampler2D uVelocity;void main(){float L=texture2D(uVelocity,vL).y;float R=texture2D(uVelocity,vR).y;float T=texture2D(uVelocity,vT).x;float B=texture2D(uVelocity,vB).x;float vorticity=R-L-T+B;gl_FragColor=vec4(vorticity,0.0,0.0,1.0);}');
var vorticityShader = compileShader(gl.FRAGMENT_SHADER, 'precision highp float;precision mediump sampler2D;varying vec2 vUv;varying vec2 vL;varying vec2 vR;varying vec2 vT;varying vec2 vB;uniform sampler2D uVelocity;uniform sampler2D uCurl;uniform float curl;uniform float dt;void main(){float L=texture2D(uCurl,vL).y;float R=texture2D(uCurl,vR).y;float T=texture2D(uCurl,vT).x;float B=texture2D(uCurl,vB).x;float C=texture2D(uCurl,vUv).x;vec2 force=vec2(abs(T)-abs(B),abs(R)-abs(L));force*=1.0/length(force+0.00001)*curl*C;vec2 vel=texture2D(uVelocity,vUv).xy;gl_FragColor=vec4(vel+force*dt,0.0,1.0);}');
var pressureShader = compileShader(gl.FRAGMENT_SHADER, 'precision highp float;precision mediump sampler2D;varying vec2 vUv;varying vec2 vL;varying vec2 vR;varying vec2 vT;varying vec2 vB;uniform sampler2D uPressure;uniform sampler2D uDivergence;vec2 boundary(in vec2 uv){uv=min(max(uv,0.0),1.0);return uv;}void main(){float L=texture2D(uPressure,boundary(vL)).x;float R=texture2D(uPressure,boundary(vR)).x;float T=texture2D(uPressure,boundary(vT)).x;float B=texture2D(uPressure,boundary(vB)).x;float C=texture2D(uPressure,vUv).x;float divergence=texture2D(uDivergence,vUv).x;float pressure=(L+R+B+T-divergence)*0.25;gl_FragColor=vec4(pressure,0.0,0.0,1.0);}');
var gradientSubtractShader = compileShader(gl.FRAGMENT_SHADER, 'precision highp float;precision mediump sampler2D;varying vec2 vUv;varying vec2 vL;varying vec2 vR;varying vec2 vT;varying vec2 vB;uniform sampler2D uPressure;uniform sampler2D uVelocity;vec2 boundary(in vec2 uv){uv=min(max(uv,0.0),1.0);return uv;}void main(){float L=texture2D(uPressure,boundary(vL)).x;float R=texture2D(uPressure,boundary(vR)).x;float T=texture2D(uPressure,boundary(vT)).x;float B=texture2D(uPressure,boundary(vB)).x;vec2 velocity=texture2D(uVelocity,vUv).xy;velocity.xy-=vec2(R-L,T-B);gl_FragColor=vec4(velocity,0.0,1.0);}');
var textureWidth, textureHeight, density, velocity, divergence, curl, pressure;
initFramebuffers();
var clearProgram = new GLProgram(baseVertexShader, clearShader);
var displayProgram = new GLProgram(baseVertexShader, displayShader);
var splatProgram = new GLProgram(baseVertexShader, splatShader);
var advectionProgram = new GLProgram(baseVertexShader, support_linear_float ? advectionShader : advectionManualFilteringShader);
var divergenceProgram = new GLProgram(baseVertexShader, divergenceShader);
var curlProgram = new GLProgram(baseVertexShader, curlShader);
var vorticityProgram = new GLProgram(baseVertexShader, vorticityShader);
var pressureProgram = new GLProgram(baseVertexShader, pressureShader);
var gradienSubtractProgram = new GLProgram(baseVertexShader, gradientSubtractShader);
function initFramebuffers() {
textureWidth = gl.drawingBufferWidth >> config.TEXTURE_DOWNSAMPLE;
textureHeight = gl.drawingBufferHeight >> config.TEXTURE_DOWNSAMPLE;
var iFormat = ext.internalFormat;
var iFormatRG = ext.internalFormatRG;
var formatRG = ext.formatRG;
var texType = ext.texType;
density = createDoubleFBO(0, textureWidth, textureHeight, iFormat, gl.RGBA, texType, support_linear_float ? gl.LINEAR : gl.NEAREST);
velocity = createDoubleFBO(2, textureWidth, textureHeight, iFormatRG, formatRG, texType, support_linear_float ? gl.LINEAR : gl.NEAREST);
divergence = createFBO(4, textureWidth, textureHeight, iFormatRG, formatRG, texType, gl.NEAREST);
curl = createFBO(5, textureWidth, textureHeight, iFormatRG, formatRG, texType, gl.NEAREST);
pressure = createDoubleFBO(6, textureWidth, textureHeight, iFormatRG, formatRG, texType, gl.NEAREST);
}
function createFBO(texId, w, h, internalFormat, format, type, param) {
gl.activeTexture(gl.TEXTURE0 + texId);
var texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, param);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, param);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texImage2D(gl.TEXTURE_2D, 0, internalFormat, w, h, 0, format, type, null);
var fbo = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
gl.viewport(0, 0, w, h);
gl.clear(gl.COLOR_BUFFER_BIT);
return [texture, fbo, texId];
}
function createDoubleFBO(texId, w, h, internalFormat, format, type, param) {
var fbo1 = createFBO(texId, w, h, internalFormat, format, type, param);
var fbo2 = createFBO(texId + 1, w, h, internalFormat, format, type, param);
return {
get first() { return fbo1; },
get second() { return fbo2; },
swap: function swap() { var temp = fbo1; fbo1 = fbo2; fbo2 = temp; }
};
}
var blit = function () {
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, -1, 1, 1, 1, 1, -1]), gl.STATIC_DRAW);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array([0, 1, 2, 0, 2, 3]), gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(0);
return function (destination) {
gl.bindFramebuffer(gl.FRAMEBUFFER, destination);
gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0);
};
}();
var lastTime = Date.now();
update();
function update() {
resizeCanvas();
var dt = Math.min((Date.now() - lastTime) / 1000, 0.016);
lastTime = Date.now();
gl.viewport(0, 0, textureWidth, textureHeight);
if (splatStack.length > 0) {
for (var m = 0; m < splatStack.pop(); m++) {
var color = [Math.random() * 10, Math.random() * 10, Math.random() * 10];
var x = canvas.width * Math.random();
var y = canvas.height * Math.random();
var dx = 1000 * (Math.random() - 0.5);
var dy = 1000 * (Math.random() - 0.5);
splat(x, y, dx, dy, color);
}
}
advectionProgram.bind();
gl.uniform2f(advectionProgram.uniforms.texelSize, 1.0 / textureWidth, 1.0 / textureHeight);
gl.uniform1i(advectionProgram.uniforms.uVelocity, velocity.first[2]);
gl.uniform1i(advectionProgram.uniforms.uSource, velocity.first[2]);
gl.uniform1f(advectionProgram.uniforms.dt, dt);
gl.uniform1f(advectionProgram.uniforms.dissipation, config.VELOCITY_DISSIPATION);
blit(velocity.second[1]);
velocity.swap();
gl.uniform1i(advectionProgram.uniforms.uVelocity, velocity.first[2]);
gl.uniform1i(advectionProgram.uniforms.uSource, density.first[2]);
gl.uniform1f(advectionProgram.uniforms.dissipation, config.DENSITY_DISSIPATION);
blit(density.second[1]);
density.swap();
for (var i = 0, len = pointers.length; i < len; i++) {
var pointer = pointers[i];
if (pointer.moved) {
splat(pointer.x, pointer.y, pointer.dx, pointer.dy, pointer.color);
pointer.moved = false;
}
}
curlProgram.bind();
gl.uniform2f(curlProgram.uniforms.texelSize, 1.0 / textureWidth, 1.0 / textureHeight);
gl.uniform1i(curlProgram.uniforms.uVelocity, velocity.first[2]);
blit(curl[1]);
vorticityProgram.bind();
gl.uniform2f(vorticityProgram.uniforms.texelSize, 1.0 / textureWidth, 1.0 / textureHeight);
gl.uniform1i(vorticityProgram.uniforms.uVelocity, velocity.first[2]);
gl.uniform1i(vorticityProgram.uniforms.uCurl, curl[2]);
gl.uniform1f(vorticityProgram.uniforms.curl, config.CURL);
gl.uniform1f(vorticityProgram.uniforms.dt, dt);
blit(velocity.second[1]);
velocity.swap();
divergenceProgram.bind();
gl.uniform2f(divergenceProgram.uniforms.texelSize, 1.0 / textureWidth, 1.0 / textureHeight);
gl.uniform1i(divergenceProgram.uniforms.uVelocity, velocity.first[2]);
blit(divergence[1]);
clearProgram.bind();
var pressureTexId = pressure.first[2];
gl.activeTexture(gl.TEXTURE0 + pressureTexId);
gl.bindTexture(gl.TEXTURE_2D, pressure.first[0]);
gl.uniform1i(clearProgram.uniforms.uTexture, pressureTexId);
gl.uniform1f(clearProgram.uniforms.value, config.PRESSURE_DISSIPATION);
blit(pressure.second[1]);
pressure.swap();
pressureProgram.bind();
gl.uniform2f(pressureProgram.uniforms.texelSize, 1.0 / textureWidth, 1.0 / textureHeight);
gl.uniform1i(pressureProgram.uniforms.uDivergence, divergence[2]);
pressureTexId = pressure.first[2];
gl.activeTexture(gl.TEXTURE0 + pressureTexId);
for (var _i = 0; _i < config.PRESSURE_ITERATIONS; _i++) {
gl.bindTexture(gl.TEXTURE_2D, pressure.first[0]);
gl.uniform1i(pressureProgram.uniforms.uPressure, pressureTexId);
blit(pressure.second[1]);
pressure.swap();
}
gradienSubtractProgram.bind();
gl.uniform2f(gradienSubtractProgram.uniforms.texelSize, 1.0 / textureWidth, 1.0 / textureHeight);
gl.uniform1i(gradienSubtractProgram.uniforms.uPressure, pressure.first[2]);
gl.uniform1i(gradienSubtractProgram.uniforms.uVelocity, velocity.first[2]);
blit(velocity.second[1]);
velocity.swap();
gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
displayProgram.bind();
gl.uniform1i(displayProgram.uniforms.uTexture, density.first[2]);
blit(null);
requestAnimationFrame(update);
}
function splat(x, y, dx, dy, color) {
splatProgram.bind();
gl.uniform1i(splatProgram.uniforms.uTarget, velocity.first[2]);
gl.uniform1f(splatProgram.uniforms.aspectRatio, canvas.width / canvas.height);
gl.uniform2f(splatProgram.uniforms.point, x / canvas.width, 1.0 - y / canvas.height);
gl.uniform3f(splatProgram.uniforms.color, dx, -dy, 1.0);
gl.uniform1f(splatProgram.uniforms.radius, config.SPLAT_RADIUS);
blit(velocity.second[1]);
velocity.swap();
gl.uniform1i(splatProgram.uniforms.uTarget, density.first[2]);
gl.uniform3f(splatProgram.uniforms.color, color[0] * 0.3, color[1] * 0.3, color[2] * 0.3);
blit(density.second[1]);
density.swap();
}
function resizeCanvas() {
if (canvas.width !== window.innerWidth || canvas.height !== window.innerHeight) {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
initFramebuffers();
}
}
var count = 0;
var colorArr = [Math.random() + 0.2, Math.random() + 0.2, Math.random() + 0.2];
canvas.addEventListener('mousemove', function (e) {
count++;
(count > 25) && (colorArr = [Math.random() + 0.2, Math.random() + 0.2, Math.random() + 0.2], count = 0);
pointers[0].down = true;
pointers[0].color = colorArr;
pointers[0].moved = pointers[0].down;
pointers[0].dx = (e.offsetX - pointers[0].x) * 10.0;
pointers[0].dy = (e.offsetY - pointers[0].y) * 10.0;
pointers[0].x = e.offsetX;
pointers[0].y = e.offsetY;
});
canvas.addEventListener('touchmove', function (e) {
e.preventDefault();
var touches = e.targetTouches;
count++;
(count > 25) && (colorArr = [Math.random() + 0.2, Math.random() + 0.2, Math.random() + 0.2], count = 0);
for (var i = 0, len = touches.length; i < len; i++) {
if (i >= pointers.length) pointers.push(new pointerPrototype());
pointers[i].id = touches[i].identifier;
pointers[i].down = true;
pointers[i].x = touches[i].pageX;
pointers[i].y = touches[i].pageY;
pointers[i].color = colorArr;
var pointer = pointers[i];
pointer.moved = pointer.down;
pointer.dx = (touches[i].pageX - pointer.x) * 10.0;
pointer.dy = (touches[i].pageY - pointer.y) * 10.0;
pointer.x = touches[i].pageX;
pointer.y = touches[i].pageY;
}
}, false);
function m(t) {
for (var e, n = document.getElementById(t), i = n.innerHTML.replace("&", "&").split(""), a = "", o = 0, s = i.length; s > o; o++) {
e = i[o].replace("&", "&");
a += e.trim() ? '<span class="letter-' + o + '">' + e + "</span>" : " ";
}
n.innerHTML = a;
setTimeout(function () {
n.className = "transition-in";
}, 500 * Math.random() + 500);
}
m("smoke-h1");
})();
});
</script>
</body>
</html>y ahora pasemos a la sección de RA.html
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>MindAR Ghostbusters AR</title>
<link rel="icon" type="image/png" sizes="32x32" href="./Iconos/32x32.png">
<meta name="theme-color" content="#000000">
<link rel="manifest" href="./manifest.json">
<link rel="apple-touch-icon" href="./Iconos/apple-touch-icon-180x180.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Creepster&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Lato&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Cinzel&display=swap" rel="stylesheet">
<script src="https://aframe.io/releases/1.5.0/aframe.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/mind-ar@1.2.5/dist/mindar-image-aframe.prod.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Creepster&display=swap" rel="stylesheet">
<style>
html, body {
margin: 0;
padding: 0;
width: 100vw;
height: 100vh;
overflow: hidden;
position: fixed;
overscroll-behavior: none;
background-color: #000;
}
.ghost-font {
font-family: 'Creepster', cursive;
}
#custom-loader {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 1000;
background-color: #000;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
transition: opacity 0.5s ease;
opacity: 1;
}
#custom-loader.hidden {
opacity: 0;
pointer-events: none;
}
.loader-bar-container {
width: 80%;
max-width: 400px;
height: 30px;
background-color: #333;
border: 3px solid #00ff00;
border-radius: 10px;
overflow: hidden;
}
#loader-bar-fill {
width: 0%;
height: 100%;
background-color: #00ff00;
box-shadow: 0 0 15px #00ff00, 0 0 25px #00ff00;
transition: width 0.3s ease;
}
#progress-text {
color: #00ff00;
font-size: 2.5rem;
margin-top: 20px;
text-shadow: 2px 2px 5px black;
}
#video-controls {
position: fixed;
bottom: 10%;
left: 50%;
transform: translateX(-50%);
z-index: 60;
display: none;
background: rgba(0, 0, 0, 0.5);
padding: 10px;
border-radius: 10px;
}
.ghost-button {
font-family: 'Creepster', cursive;
font-size: 2rem;
margin: 0 10px;
padding: 5px 10px;
cursor: pointer;
background-color: #50C878;
color: white;
border: 2px solid #000;
border-radius: 5px;
text-shadow: 1px 1px 2px black;
}
#logo-inferior {
position: fixed;
bottom: 15px;
left: 50%;
transform: translateX(-50%);
z-index: 50;
width: 25vw;
max-width: 150px;
min-width: 90px;
}
#logo-inferior img {
width: 100%;
height: auto;
}
@media (min-width: 768px) {
#logo-inferior {
width: 180px;
}
}
.corner-image {
position: fixed;
z-index: 40;
width: 18vw;
height: auto;
max-width: 90px;
min-width: 50px;
}
#top-left-corner {
top: 10px;
left: 10px;
}
#top-right-corner {
top: 10px;
right: 10px;
}
#bottom-left-corner {
bottom: 10px;
left: 10px;
}
#bottom-right-corner {
bottom: 10px;
right: 10px;
}
@media (min-width: 768px) {
.corner-image {
width: 120px;
max-width: none;
min-width: none;
}
}
</style>
</head>
<body>
<div id="custom-loader">
<div class="loader-bar-container">
<div id="loader-bar-fill"></div>
</div>
<div id="progress-text" class="ghost-font">CARGANDO... 0%</div>
</div>
<div id="video-controls">
<button id="mute-unmute-button" class="ghost-button">SONIDO</button>
</div>
<a id="logo-inferior" href="#" target="_blank">
<img src="./Banner.png" alt="Logo">
</a>
<img id="top-left-corner" class="corner-image" src="./Izquierda.png" alt="Esquina Izquierda Superior">
<img id="bottom-left-corner" class="corner-image" src="./Izquierda.png" alt="Esquina Izquierda Inferior">
<img id="top-right-corner" class="corner-image" src="./Derecha.png" alt="Esquina Derecha Superior">
<img id="bottom-right-corner" class="corner-image" src="./Derecha.png" alt="Esquina Derecha Inferior">
<a-scene
id="ar-scene"
mindar-image="imageTargetSrc: ./Marker.mind; maxTrack: 3; filterMinCF: 0.001; filterBeta: 1000;"
color-space="sRGB"
renderer="colorManagement: true, physicallyCorrectLights"
vr-mode-ui="enabled: false"
device-orientation-permission-ui="enabled: false"
embedded
loading-screen="enabled: false"
>
<a-assets>
<video
id="video-ghostbusters"
preload="auto"
src="./GhostBusters.mp4"
loop="true"
crossorigin="anonymous"
playsinline
webkit-playsinline
muted
></video>
<video
id="video-1"
preload="auto"
src="./1.webm"
loop="true"
crossorigin="anonymous"
playsinline
webkit-playsinline
muted
></video>
<a-asset-item
id="protones-model"
src="./protones.glb"
></a-asset-item>
</a-assets>
<a-camera position="0 0 0" look-controls="enabled: false"></a-camera>
<a-entity id="target-0" mindar-image-target="targetIndex: 0">
<a-video
src="#video-ghostbusters"
position="0 0 0"
rotation="0 0 0"
width="2"
height="1"
></a-video>
</a-entity>
<a-entity id="target-1" mindar-image-target="targetIndex: 1">
<a-video
src="#video-1"
position="0 0 0"
rotation="0 0 0"
width="2"
height="1"
></a-video>
</a-entity>
<a-entity id="target-2" mindar-image-target="targetIndex: 2">
<a-gltf-model
src="#protones-model"
position="0 0 0"
rotation="0 0 0"
scale="0.1 0.1 0.1"
animation-mixer
></a-gltf-model>
</a-entity>
</a-scene>
<script>
'use strict';
document.addEventListener('DOMContentLoaded', () => {
const arScene = document.getElementById('ar-scene');
const loader = document.getElementById('custom-loader');
const loaderBar = document.getElementById('loader-bar-fill');
const progressText = document.getElementById('progress-text');
arScene.addEventListener('progress', (event) => {
const progress = event.detail.progress * 100;
const progressInt = Math.round(progress) || 0;
loaderBar.style.width = progress + '%';
progressText.textContent = `CARGANDO... ${progressInt}%`;
});
arScene.addEventListener('loaded', () => {
loader.classList.add('hidden');
setTimeout(() => {
arScene.systems['mindar-image-system'].start();
}, 500);
const videoControls = document.getElementById('video-controls');
const muteUnmuteButton = document.getElementById('mute-unmute-button');
const video0 = document.getElementById('video-ghostbusters');
const video1 = document.getElementById('video-1');
const target0 = document.getElementById('target-0');
const target1 = document.getElementById('target-1');
let target0Visible = false;
let target1Visible = false;
const updateControlsVisibility = () => {
if (target0Visible || target1Visible) {
videoControls.style.display = 'flex';
} else {
videoControls.style.display = 'none';
}
};
muteUnmuteButton.addEventListener('click', () => {
if (video0.muted) {
video0.muted = false;
video1.muted = false;
muteUnmuteButton.textContent = 'MUTE';
} else {
video0.muted = true;
video1.muted = true;
muteUnmuteButton.textContent = 'SOUND';
}
});
target0.addEventListener('targetFound', () => {
video0.play();
target0Visible = true;
updateControlsVisibility();
});
target0.addEventListener('targetLost', () => {
video0.pause();
target0Visible = false;
updateControlsVisibility();
});
target1.addEventListener('targetFound', () => {
video1.play();
target1Visible = true;
updateControlsVisibility();
});
target1.addEventListener('targetLost', () => {
video1.pause();
target1Visible = false;
updateControlsVisibility();
});
});
});
</script>
</body>
</html>ĀæQuieres todo el proyecto en un solo archivo?
PATREON Y DESCARGA: CLIC AQUI
Visita mi patreon y agrƩgate a la comunidad.
Que encontraras en el archivo .zip de este tutorial:
- Archivo Index.html, RA.html.
- ImƔgenes y marcadores (.mind)
- ImƔgenes complementarias de la app.
- Iconos para tu PWA de esta WebAR.
- Modelos 3D y videos.
- Código lĆnea por lĆnea comentado para que conozcas y aprendas que hace cada lĆnea de código.
Te recomiendo estos posts: