/* * FOSFENO :: Mapper (projection mapping en el navegador) * -------------------------------------------------------------------------- * Coge el canvas del motor activo (butterchurn/hydra/shaders/mixer) como * TEXTURA y lo pinta deformado sobre una o varias SUPERFICIES (cuadriláteros) * en un canvas WebGL a pantalla completa (#output). Cada superficie se ajusta * con el ratón: crear, arrastrar esquinas, mover, borrar. La geometría se * guarda en el servidor (data/mapping.json) y se sincroniza por WebSocket. * * Deformación: homografía "cuadrado unidad -> cuadrilátero" (Heckbert), * evaluada por vértice sobre una malla subdividida => keystone/perspectiva * correctos. La base ya sirve para crecer a malla curva y máscaras. * * API global (la usa stage.js): * FosMapper.init({ onChange }) arranca (crea contextos y el bucle) * FosMapper.setSource(canvas) canvas del motor activo (o null) * FosMapper.setMapping(m) { enabled, edit, surfaces:[{id,corners}] } * onChange(surfaces) se llama al editar (arrastrar) para guardar * * corners = [ [x,y] x4 ] en 0..1 (TL, TR, BR, BL), origen arriba-izquierda. */ (function () { 'use strict'; const GRID = 12; // subdivisiones de la malla (perspectiva suave) const HANDLE = 11; // radio del tirador de esquina (px CSS) const M = { enabled: false, edit: false, surfaces: [] }; let source = null; // canvas del motor activo let onChange = null; let outputEl, editEl, gl, ectx; let prog, aUV, uH, uTex, tex, quadBuf, idxBuf, idxCount; let dpr = 1; // interacción let selected = null; // id de superficie seleccionada let drag = null; // { id, corner|null, startX, startY, orig } let dragged = false; // hubo movimiento real (para no emitir de más) // ------------------------------------------------------------------ utils const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v); function defaultSurface(cx, cy, s) { // cuadrilátero centrado en (cx,cy) de semilado s (en 0..1) cx = cx == null ? 0.5 : cx; cy = cy == null ? 0.5 : cy; s = s == null ? 0.28 : s; return { id: 's' + Math.floor(performance.now()) + '_' + Math.floor(Math.random() * 1e4), corners: [ [clamp01(cx - s), clamp01(cy - s)], [clamp01(cx + s), clamp01(cy - s)], [clamp01(cx + s), clamp01(cy + s)], [clamp01(cx - s), clamp01(cy + s)], ], }; } // Homografía cuadrado unidad -> cuadrilátero (corners TL,TR,BR,BL, y hacia // abajo). Devuelve mat3 en orden por columnas para WebGL. (Heckbert 1989) function squareToQuad(c) { const x0 = c[0][0], y0 = c[0][1]; const x1 = c[1][0], y1 = c[1][1]; const x2 = c[2][0], y2 = c[2][1]; const x3 = c[3][0], y3 = c[3][1]; const dx1 = x1 - x2, dx2 = x3 - x2, dx3 = x0 - x1 + x2 - x3; const dy1 = y1 - y2, dy2 = y3 - y2, dy3 = y0 - y1 + y2 - y3; let a, b, cc, d, e, f, g, h; if (Math.abs(dx3) < 1e-9 && Math.abs(dy3) < 1e-9) { a = x1 - x0; b = x2 - x1; cc = x0; d = y1 - y0; e = y2 - y1; f = y0; g = 0; h = 0; } else { const den = dx1 * dy2 - dy1 * dx2 || 1e-9; g = (dx3 * dy2 - dy3 * dx2) / den; h = (dx1 * dy3 - dy1 * dx3) / den; a = x1 - x0 + g * x1; b = x3 - x0 + h * x3; cc = x0; d = y1 - y0 + g * y1; e = y3 - y0 + h * y3; f = y0; } // columnas: col0=(a,d,g) col1=(b,e,h) col2=(cc,f,1) return new Float32Array([a, d, g, b, e, h, cc, f, 1]); } // ------------------------------------------------------------------ WebGL function initGL() { gl = outputEl.getContext('webgl', { alpha: false, antialias: true, premultipliedAlpha: false }) || outputEl.getContext('experimental-webgl'); if (!gl) { console.error('[FOSFENO] mapper: sin WebGL'); return false; } const vs = ` attribute vec2 aUV; uniform mat3 uH; varying vec2 vUV; void main() { vec3 p = uH * vec3(aUV, 1.0); vec2 d = p.xy / p.z; // destino 0..1, y hacia abajo gl_Position = vec4(d.x * 2.0 - 1.0, 1.0 - d.y * 2.0, 0.0, 1.0); vUV = aUV; }`; const fs = ` precision mediump float; varying vec2 vUV; uniform sampler2D uTex; void main() { gl_FragColor = texture2D(uTex, vUV); }`; prog = link(vs, fs); if (!prog) return false; aUV = gl.getAttribLocation(prog, 'aUV'); uH = gl.getUniformLocation(prog, 'uH'); uTex = gl.getUniformLocation(prog, 'uTex'); // malla GRIDxGRID de vértices con uv en 0..1 const verts = []; for (let j = 0; j <= GRID; j++) for (let i = 0; i <= GRID; i++) verts.push(i / GRID, j / GRID); const idx = []; const row = GRID + 1; for (let j = 0; j < GRID; j++) for (let i = 0; i < GRID; i++) { const p = j * row + i; idx.push(p, p + 1, p + row, p + 1, p + row + 1, p + row); } idxCount = idx.length; quadBuf = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(verts), gl.STATIC_DRAW); idxBuf = gl.createBuffer(); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, idxBuf); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(idx), gl.STATIC_DRAW); tex = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, tex); 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.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); return true; } function link(vsSrc, fsSrc) { const c = (t, s) => { const sh = gl.createShader(t); gl.shaderSource(sh, s); gl.compileShader(sh); if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) console.error('[FOSFENO] mapper shader:', gl.getShaderInfoLog(sh)); return sh; }; const p = gl.createProgram(); gl.attachShader(p, c(gl.VERTEX_SHADER, vsSrc)); gl.attachShader(p, c(gl.FRAGMENT_SHADER, fsSrc)); gl.linkProgram(p); if (!gl.getProgramParameter(p, gl.LINK_STATUS)) { console.error('[FOSFENO] mapper link:', gl.getProgramInfoLog(p)); return null; } return p; } function renderGL() { gl.viewport(0, 0, outputEl.width, outputEl.height); gl.clearColor(0, 0, 0, 1); gl.clear(gl.COLOR_BUFFER_BIT); if (!source || !source.width) return; // sube el frame actual del motor a la textura gl.bindTexture(gl.TEXTURE_2D, tex); try { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source); } catch (e) { return; } gl.useProgram(prog); gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf); gl.enableVertexAttribArray(aUV); gl.vertexAttribPointer(aUV, 2, gl.FLOAT, false, 0, 0); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, idxBuf); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, tex); gl.uniform1i(uTex, 0); const surfs = M.surfaces.length ? M.surfaces : [{ corners: [[0, 0], [1, 0], [1, 1], [0, 1]] }]; // sin superficies: pasada directa for (const s of surfs) { gl.uniformMatrix3fv(uH, false, squareToQuad(s.corners)); gl.drawElements(gl.TRIANGLES, idxCount, gl.UNSIGNED_SHORT, 0); } } // ------------------------------------------------------------------ editor function drawEditor() { const w = window.innerWidth, h = window.innerHeight; ectx.setTransform(dpr, 0, 0, dpr, 0, 0); ectx.clearRect(0, 0, w, h); ectx.font = '600 14px system-ui, sans-serif'; ectx.textAlign = 'center'; ectx.textBaseline = 'middle'; M.surfaces.forEach((s, i) => { const pts = s.corners.map(([x, y]) => [x * w, y * h]); const sel = s.id === selected; // relleno + contorno ectx.beginPath(); ectx.moveTo(pts[0][0], pts[0][1]); for (let k = 1; k < 4; k++) ectx.lineTo(pts[k][0], pts[k][1]); ectx.closePath(); ectx.fillStyle = sel ? 'rgba(0,229,255,0.12)' : 'rgba(180,255,0,0.06)'; ectx.fill(); ectx.lineWidth = sel ? 3 : 2; ectx.strokeStyle = sel ? '#00e5ff' : '#b4ff00'; ectx.stroke(); // número de superficie en el centro const cx = (pts[0][0] + pts[1][0] + pts[2][0] + pts[3][0]) / 4; const cy = (pts[0][1] + pts[1][1] + pts[2][1] + pts[3][1]) / 4; ectx.fillStyle = sel ? '#00e5ff' : 'rgba(180,255,0,0.85)'; ectx.fillText(String(i + 1), cx, cy); // tiradores de esquina pts.forEach(([px, py], ci) => { ectx.beginPath(); ectx.arc(px, py, HANDLE, 0, Math.PI * 2); ectx.fillStyle = sel ? '#00e5ff' : '#ffffff'; ectx.fill(); ectx.lineWidth = 2; ectx.strokeStyle = '#000'; ectx.stroke(); void ci; }); }); // ayuda ectx.textAlign = 'left'; ectx.fillStyle = 'rgba(255,255,255,0.6)'; ectx.font = '13px system-ui, sans-serif'; ectx.fillText('MAPPING · arrastra esquinas · mueve dentro · doble clic: nueva · Supr: borrar', 16, h - 18); } function pick(mx, my) { const w = window.innerWidth, h = window.innerHeight; // primero esquinas (de la más reciente/encima hacia atrás) for (let i = M.surfaces.length - 1; i >= 0; i--) { const s = M.surfaces[i]; for (let c = 0; c < 4; c++) { const px = s.corners[c][0] * w, py = s.corners[c][1] * h; if (Math.hypot(mx - px, my - py) <= HANDLE + 6) return { id: s.id, corner: c }; } } // luego interior de una superficie for (let i = M.surfaces.length - 1; i >= 0; i--) { const s = M.surfaces[i]; if (pointInQuad(mx / w, my / h, s.corners)) return { id: s.id, corner: null }; } return null; } function pointInQuad(x, y, c) { let inside = false; for (let i = 0, j = 3; i < 4; j = i++) { const xi = c[i][0], yi = c[i][1], xj = c[j][0], yj = c[j][1]; if (((yi > y) !== (yj > y)) && (x < ((xj - xi) * (y - yi)) / ((yj - yi) || 1e-9) + xi)) inside = !inside; } return inside; } const surfaceById = (id) => M.surfaces.find((s) => s.id === id); function onDown(ev) { const mx = ev.clientX, my = ev.clientY; const hit = pick(mx, my); dragged = false; if (!hit) { selected = null; drag = null; return; } selected = hit.id; const s = surfaceById(hit.id); drag = { id: hit.id, corner: hit.corner, startX: mx, startY: my, orig: s.corners.map((p) => p.slice()), }; ev.preventDefault(); } function onMove(ev) { if (!drag) return; const w = window.innerWidth, h = window.innerHeight; const dx = (ev.clientX - drag.startX) / w; const dy = (ev.clientY - drag.startY) / h; if (Math.abs(ev.clientX - drag.startX) + Math.abs(ev.clientY - drag.startY) > 1) dragged = true; const s = surfaceById(drag.id); if (!s) return; if (drag.corner == null) { for (let c = 0; c < 4; c++) { s.corners[c][0] = clamp01(drag.orig[c][0] + dx); s.corners[c][1] = clamp01(drag.orig[c][1] + dy); } } else { s.corners[drag.corner][0] = clamp01(drag.orig[drag.corner][0] + dx); s.corners[drag.corner][1] = clamp01(drag.orig[drag.corner][1] + dy); } scheduleEmit(); } function onUp() { if (drag && dragged) emitNow(); drag = null; } function onDblClick(ev) { const s = defaultSurface(ev.clientX / window.innerWidth, ev.clientY / window.innerHeight, 0.2); M.surfaces.push(s); selected = s.id; emitNow(); } function onKey(ev) { if (!M.edit) return; if ((ev.key === 'Delete' || ev.key === 'Backspace') && selected) { M.surfaces = M.surfaces.filter((s) => s.id !== selected); selected = null; emitNow(); ev.preventDefault(); } } // emisión (throttle durante el arrastre, inmediata al soltar) let emitTimer = null; function scheduleEmit() { if (emitTimer) return; emitTimer = setTimeout(() => { emitTimer = null; emitNow(); }, 60); } function emitNow() { if (emitTimer) { clearTimeout(emitTimer); emitTimer = null; } if (onChange) onChange(M.surfaces.map((s) => ({ id: s.id, corners: s.corners }))); } // ------------------------------------------------------------------ tamaños function fit(el, wantGL) { const w = Math.round(window.innerWidth * dpr); const h = Math.round(window.innerHeight * dpr); if (el.width !== w || el.height !== h) { el.width = w; el.height = h; } void wantGL; } // ------------------------------------------------------------------ bucle function loop() { requestAnimationFrame(loop); dpr = Math.min(2, window.devicePixelRatio || 1); const editing = M.enabled && M.edit; outputEl.style.display = M.enabled ? 'block' : 'none'; editEl.style.display = editing ? 'block' : 'none'; editEl.style.pointerEvents = editing ? 'auto' : 'none'; document.body.style.cursor = editing ? 'crosshair' : 'none'; if (!M.enabled) return; fit(outputEl, true); fit(editEl, false); renderGL(); if (editing) drawEditor(); } // ------------------------------------------------------------------ API const API = { init(opts) { opts = opts || {}; onChange = opts.onChange || null; outputEl = document.getElementById('output'); editEl = document.getElementById('mapedit'); if (!outputEl || !editEl) { console.error('[FOSFENO] mapper: faltan #output/#mapedit'); return; } ectx = editEl.getContext('2d'); if (!initGL()) return; editEl.addEventListener('mousedown', onDown); window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); editEl.addEventListener('dblclick', onDblClick); window.addEventListener('keydown', onKey); requestAnimationFrame(loop); }, setSource(canvas) { source = canvas || null; }, setMapping(m) { m = m || {}; M.enabled = !!m.enabled; M.edit = !!m.edit; // no piso la geometría mientras se está arrastrando en esta pantalla if (!drag && Array.isArray(m.surfaces)) { M.surfaces = m.surfaces.map((s) => ({ id: s.id, corners: s.corners.map((p) => [p[0], p[1]]), })); if (selected && !surfaceById(selected)) selected = null; } }, getMapping() { return M; }, }; window.FosMapper = API; })();