// Modo libro 3D: un solo canvas WebGL2 con la mesa, el libro y una cámara de verdad. Hojas y cantos salen de la misma // función de altura, así que no pueden separarse. El lector solo le dice qué páginas se ven. // Unidades: la página mide 1 de alto. x cruza el libro (la costura en 0), y va hacia arriba de la pantalla, z sale de la mesa. export const BOOK = { THICK: 0.075, BOARD: 0.008, OVER: 0.004, CROWN: 0.05, FALL: 0.02, DIP: 0.06, SPINE: 0.03 } const dipAt = t => Math.exp(-t / BOOK.DIP) // Lo que se levanta la hoja sobre un bloque plano. t: 0 en la costura, 1 en el borde exterior. open: 0 cerrado, 1 abierto. export const arch = (t, open = 1) => open * (BOOK.CROWN * (1 - dipAt(t)) - BOOK.FALL * t * t) // Altura de la hoja a profundidad v (0: la de arriba; 1: la de abajo). La curva se atenúa hasta la mitad en la última. // k: 1 con el bloque entero; 0 sin hojas (la mitad sobre la que aún no ha aterrizado la cubierta). export function sheetZ(t, v = 0, open = 1, k = 1) { return BOOK.BOARD + k * (BOOK.THICK * (1 - v) * (1 - 0.45 * dipAt(t) * open) + arch(t, open) * (1 - v / 2)) } export const surface = (t, open = 1, k = 1) => sheetZ(t, 0, open, k) const LID_Z = BOOK.BOARD * 2 + BOOK.THICK // la cara de arriba de la cubierta con el libro cerrado const smooth = (a, b, x) => { const t = Math.max(0, Math.min(1, (x - a) / (b - a))); return t * t * (3 - 2 * t) } const easeIO = t => (t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2) // La mitad sobre la que aterriza la cubierta: hasta que está casi abajo no hay nada, solo la mesa. Después la cubierta ya la tapa. export const landWidth = a => (a < 0.97 ? 0 : Math.max(0, -Math.cos(a * Math.PI))) // Una hoja a media vuelta sobre la costura. a: 0 tumbada en su lado, 1 tumbada en el otro. rigid: la cubierta, que no se dobla. // Parte de la hoja en reposo y vuelve a ella: al empezar y al acabar coincide con la superficie del libro, sin saltos. export function sheetCurve(a, side, W, rigid = false, cols = 30, ext = 0) { const L = rigid ? 0 : 0.38, pts = [] const hinge = rigid ? LID_Z + (surface(0) - LID_Z) * a : surface(0) + 0.0015 const rest = t => (rigid ? LID_Z + (surface(t) - LID_Z) * a : surface(t) + 0.0015) let x = 0, z = 0, prev = 0 for (let c = 0; c <= cols; c++) { const t = (c / cols) ** 2, phi = Math.PI * (rigid ? a : smooth(0, 1, a * (1 + L) - L * t)) x += Math.cos(phi) * (t - prev) * (W + ext) z += Math.sin(phi) * (t - prev) * (W + ext) prev = t pts.push({ t, phi, x: side * x, z: hinge + z + (rest(t) - hinge) * Math.cos(phi) ** 2 }) } pts.forEach((p, c) => { const q = pts[Math.min(cols, c + 1)], o = pts[Math.max(0, c - 1)], tx = q.x - o.x, tz = q.z - o.z, l = Math.hypot(tx, tz) || 1 p.nx = (side * -tz) / l p.nz = (side * tx) / l }) return pts } // Matriz de color de un filtro CSS (filas: r, g, b, desplazamiento), para aplicarlo a las páginas y no a la mesa. export function filterMatrix(css) { let M = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0]] const then = N => { M = N.map(r => [0, 1, 2, 3].map(c => r[0] * M[0][c] + r[1] * M[1][c] + r[2] * M[2][c] + (c === 3 ? r[3] : 0))) } for (const [, name, val] of String(css || '').matchAll(/([a-z-]+)\(([-\d.]+)(?:deg|%)?\)/g)) { const v = Number(val), i = 1 - v if (name === 'brightness') then([[v, 0, 0, 0], [0, v, 0, 0], [0, 0, v, 0]]) else if (name === 'contrast') { const o = (1 - v) / 2; then([[v, 0, 0, o], [0, v, 0, o], [0, 0, v, o]]) } else if (name === 'invert') { const k = 1 - 2 * v; then([[k, 0, 0, v], [0, k, 0, v], [0, 0, k, v]]) } else if (name === 'grayscale') then([[0.2126 + 0.7874 * i, 0.7152 - 0.7152 * i, 0.0722 - 0.0722 * i, 0], [0.2126 - 0.2126 * i, 0.7152 + 0.2848 * i, 0.0722 - 0.0722 * i, 0], [0.2126 - 0.2126 * i, 0.7152 - 0.7152 * i, 0.0722 + 0.9278 * i, 0]]) else if (name === 'sepia') then([[0.393 + 0.607 * i, 0.769 - 0.769 * i, 0.189 - 0.189 * i, 0], [0.349 - 0.349 * i, 0.686 + 0.314 * i, 0.168 - 0.168 * i, 0], [0.272 - 0.272 * i, 0.534 - 0.534 * i, 0.131 + 0.869 * i, 0]]) else if (name === 'saturate') then([[0.213 + 0.787 * v, 0.715 - 0.715 * v, 0.072 - 0.072 * v, 0], [0.213 - 0.213 * v, 0.715 + 0.285 * v, 0.072 - 0.072 * v, 0], [0.213 - 0.213 * v, 0.715 - 0.715 * v, 0.072 + 0.928 * v, 0]]) else if (name === 'hue-rotate') { const c = Math.cos((v * Math.PI) / 180), n = Math.sin((v * Math.PI) / 180) then([[0.213 + c * 0.787 - n * 0.213, 0.715 - c * 0.715 - n * 0.715, 0.072 - c * 0.072 + n * 0.928, 0], [0.213 - c * 0.213 + n * 0.143, 0.715 + c * 0.285 + n * 0.14, 0.072 - c * 0.072 - n * 0.283, 0], [0.213 - c * 0.213 - n * 0.787, 0.715 - c * 0.715 + n * 0.715, 0.072 + c * 0.928 + n * 0.072, 0]]) } } return M } const norm = v => { const l = Math.hypot(...v) || 1; return v.map(n => n / l) } const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2] // Perspectiva cerrada e inclinación mayor que medio campo de visión: así la cámara queda al sur del libro y se ve el canto de abajo. export const FOV = (14 * Math.PI) / 180, TILT = (14 * Math.PI) / 180, NEAR = 0.5, FAR = 40 // Matriz de la cámara, por columnas. tilt: inclinación hacia el sur, para ver el libro con algo de ángulo. export function cameraMatrix(target, dist, aspect, tilt = TILT, yaw = 0) { const eye = [target[0] + dist * Math.sin(yaw) * Math.cos(tilt), target[1] - dist * Math.sin(tilt), target[2] + dist * Math.cos(yaw) * Math.cos(tilt)] const f = norm([target[0] - eye[0], target[1] - eye[1], target[2] - eye[2]]), s = norm(cross(f, [0, 1, 0])), u = cross(s, f) const k = 1 / Math.tan(FOV / 2), A = (FAR + NEAR) / (NEAR - FAR), B = (2 * FAR * NEAR) / (NEAR - FAR) const row = (v, o) => [v[0], v[1], v[2], o] const V0 = row(s, -dot(s, eye)), V1 = row(u, -dot(u, eye)), V2 = row(f.map(n => -n), dot(f, eye)) const m = new Float32Array(16) for (let c = 0; c < 4; c++) { m[c * 4] = (k / aspect) * V0[c] m[c * 4 + 1] = k * V1[c] m[c * 4 + 2] = A * V2[c] + (c === 3 ? B : 0) m[c * 4 + 3] = -V2[c] } return m } export function project(m, p) { const w = m[3] * p[0] + m[7] * p[1] + m[11] * p[2] + m[15] return [(m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12]) / w, (m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13]) / w] } // Distancia y objetivo para que los puntos llenen la pantalla, centrados y dejando margen (en fracción de la pantalla). export function fitCamera(points, aspect, marginX, marginY) { const target = [0, 0, 0] const n = points.length for (let k = 0; k < 2; k++) target[k] = points.reduce((a, p) => a + p[k], 0) / n const limX = 1 - 2 * marginX, limY = 1 - 2 * marginY let lo = 0.5, hi = 30 const extent = d => { for (let pass = 0; pass < 5; pass++) { // recentrar: con perspectiva e inclinación el centro se desplaza const m = cameraMatrix(target, d, aspect), q = points.map(p => project(m, p)) const xs = q.map(a => a[0]), ys = q.map(a => a[1]) const midX = (Math.max(...xs) + Math.min(...xs)) / 2, midY = (Math.max(...ys) + Math.min(...ys)) / 2 const half = d * Math.tan(FOV / 2) target[0] += midX * half * aspect target[1] += midY * half } const m = cameraMatrix(target, d, aspect), q = points.map(p => project(m, p)) const xs = q.map(a => a[0]), ys = q.map(a => a[1]) return Math.max((Math.max(...xs) - Math.min(...xs)) / 2 / limX, (Math.max(...ys) - Math.min(...ys)) / 2 / limY) } for (let i = 0; i < 26; i++) { const d = (lo + hi) / 2 if (extent(d) > 1) lo = d; else hi = d } extent(hi) return { target, dist: hi } } // Colores de la portada para la contraportada y sus botones. const luma = c => { const f = v => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4); return 0.2126 * f(c[0]) + 0.7152 * f(c[1]) + 0.0722 * f(c[2]) } const contrast = (a, b) => { const x = luma(a), y = luma(b); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05) } const inkOn = c => (contrast(c, [1, 1, 1]) >= contrast(c, [0.07, 0.06, 0.05]) ? [1, 1, 1] : [0.07, 0.06, 0.05]) const mixColor = (a, b, t) => a.map((n, k) => n + (b[k] - n) * t) const css = c => `rgb(${c.map(n => Math.round(n * 255)).join(' ')})` // Color dominante y color de acento: el acento es el que más destaca sobre el dominante, sin ser una mota de la imagen. export function coverPalette(data) { const boxes = new Map() for (let p = 0; p < data.length; p += 4) { const key = ((data[p] >> 4) << 8) | ((data[p + 1] >> 4) << 4) | (data[p + 2] >> 4) const b = boxes.get(key) || { n: 0, c: [0, 0, 0] } b.n++ for (let k = 0; k < 3; k++) b.c[k] += data[p + k] / 255 boxes.set(key, b) } const list = [...boxes.values()].map(b => ({ n: b.n, c: b.c.map(v => v / b.n) })).sort((a, b) => b.n - a.n) const base = list[0]?.c || [0.2, 0.14, 0.1] let accent = null, best = 0 for (const b of list) { const sat = Math.max(...b.c) - Math.min(...b.c), score = Math.sqrt(b.n) * Math.hypot(b.c[0] - base[0], b.c[1] - base[1], b.c[2] - base[2]) * (0.35 + sat) if (score > best) { best = score; accent = b.c } } accent ||= base // Si apenas se distingue del dominante, se aclara o se oscurece hasta que se vea. const away = luma(base) > 0.3 ? [0, 0, 0] : [1, 1, 1] for (let i = 0; i < 10 && contrast(accent, base) < 2.4; i++) accent = mixColor(accent, away, 0.18) return { base, accent } } // Madera generada: se repite sin que se note el corte. function woodCanvas() { const S = 1024, PLANKS = 5, canvas = Object.assign(document.createElement('canvas'), { width: S, height: S }), g = canvas.getContext('2d') let seed = 7 const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647 for (let p = 0; p < PLANKS; p++) { const x0 = (p * S) / PLANKS, w = S / PLANKS, light = 34 + rnd() * 7 g.save() g.beginPath(); g.rect(x0, 0, w, S); g.clip() g.fillStyle = `hsl(${24 + rnd() * 5}, 46%, ${light}%)` g.fillRect(x0, 0, w, S) const phase = rnd() * 6, swing = 4 + rnd() * 10 for (let i = 0; i < 170; i++) { const x = x0 + rnd() * w, dark = rnd() < 0.72 g.strokeStyle = dark ? `rgba(40,20,8,${0.05 + rnd() * 0.16})` : `rgba(255,214,160,${0.02 + rnd() * 0.07})` g.lineWidth = 0.5 + rnd() * 2.4 g.beginPath() for (let y = 0; y <= S; y += 32) { const turn = (y / S) * Math.PI * 2 const dx = Math.sin(turn + phase) * swing + Math.sin(turn * 3 + x * 0.05) * 1.6 if (y) g.lineTo(x + dx, y); else g.moveTo(x + dx, y) } g.stroke() } g.restore() g.fillStyle = 'rgba(20,9,3,.55)' g.fillRect(x0 - 1, 0, 2, S) } return canvas } const VS = `#version 300 es uniform mat4 uM; layout(location = 0) in vec3 aPos; layout(location = 1) in vec3 aNor; layout(location = 2) in vec2 aUV; out vec3 vPos; out vec3 vNor; out vec2 vUV; void main() { vPos = aPos; vNor = aNor; vUV = aUV; gl_Position = uM * vec4(aPos, 1.0); }` const FS = `#version 300 es precision highp float; uniform int uKind; uniform sampler2D uTex; uniform vec3 uColor; uniform vec4 uMap, uRect; uniform float uSoft, uAlpha, uSeamAt, uHas, uFlip, uMirror; uniform vec2 uCast; uniform vec3 uTab; uniform mat3 uFx; uniform vec3 uFxO; in vec3 vPos; in vec3 vNor; in vec2 vUV; out vec4 color; const vec3 L = normalize(vec3(-0.16, -0.26, 0.95)); // Luz: 1 con la hoja plana; se oscurece al inclinarse hacia la costura o en las caras laterales. float light() { return clamp(mix(0.55, 1.0, max(dot(normalize(vNor) * uFlip, L), 0.0) / L.z), 0.0, 1.06); } // La sombra de la hoja que gira: desde la costura hasta donde cae la proyección de su borde. float castShade() { if (uCast.y <= 0.0 || abs(uCast.x) < 0.001) return 1.0; float r = vPos.x / uCast.x; if (r <= 0.0) return 1.0; // La hoja solo mide lo que mide el libro: su sombra no llega a la mesa de más arriba ni de más abajo. float inY = 1.0 - smoothstep(0.5, 0.56, abs(vPos.y)); return 1.0 - uCast.y * inY * (1.0 - smoothstep(0.7, 1.15, r)); } vec3 fx(vec3 c) { return clamp(uFx * c + uFxO, 0.0, 1.0); } // Misma hendidura que el lector normal, para que la página se vea igual. float gutter(float d) { if (d < 0.012) return mix(0.45, 0.62, d / 0.012); if (d < 0.03) return mix(0.62, 0.82, (d - 0.012) / 0.018); if (d < 0.06) return mix(0.82, 0.94, (d - 0.03) / 0.03); if (d < 0.1) return mix(0.94, 1.0, (d - 0.06) / 0.04); return 1.0; } void main() { if (uKind == 0) { // mesa: la luz cae sobre el libro y se apaga hacia fuera vec3 wood = uTab.z > 0.0 ? texture(uTex, vec2(0.5 + vPos.x * uTab.x, 0.5 - vPos.y * uTab.y), -0.4).rgb : texture(uTex, vUV, -0.4).rgb; float r = length((vPos.xy - uRect.xy) / uRect.zw); color = vec4(wood * mix(1.08, 0.62, smoothstep(0.5, 2.1, r)) * castShade(), 1.0); } else if (uKind == 1) { // sombra en la mesa: un rectángulo difuso vec2 a = smoothstep(uRect.xy - uSoft, uRect.xy + uSoft, vPos.xy) * (1.0 - smoothstep(uRect.zw - uSoft, uRect.zw + uSoft, vPos.xy)); color = vec4(0.05, 0.02, 0.0, 1.0) * uAlpha * a.x * a.y; } else if (uKind == 2) { // tapa color = vec4(uColor * light() * castShade(), 1.0); } else if (uKind == 3) { // canto: cada línea es una hoja, y sigue la curva porque la malla la sigue float v = vUV.y * 15.0, w = fwidth(v); float line = 1.0 - smoothstep(0.0, max(w, 0.35), abs(fract(v) - 0.5) - 0.12); vec3 c = uColor * (1.0 - 0.17 * line) * mix(0.82, 1.0, smoothstep(0.0, 0.45, vUV.y)); color = vec4(fx(c) * min(1.0, light() * 1.3) * castShade(), 1.0); // el papel de los cantos recibe luz rebotada de la mesa } else if (uKind == 4) { // página float ux = uMirror > 0.5 ? 1.0 - vUV.x : vUV.x; vec2 q = vec2(ux * uMap.x + uMap.y, vUV.y * uMap.z + uMap.w); vec3 c = uColor; if (uHas > 0.5 && q.x >= 0.0 && q.x <= 1.0 && q.y >= 0.0 && q.y <= 1.0) c = texture(uTex, q, -0.5).rgb; color = vec4(fx(c) * light() * gutter(uSeamAt < 0.5 ? ux : 1.0 - ux) * castShade(), 1.0); } else if (uKind == 5) { // portada, plana y sin hendidura color = vec4(fx(uHas > 0.5 ? texture(uTex, vUV, -0.5).rgb : uColor) * light() * castShade(), 1.0); } else { // lomo: el color del borde de la portada, de arriba abajo color = vec4(fx(uHas > 0.5 ? texture(uTex, vec2(uSeamAt, vUV.y)).rgb : uColor) * light() * castShade(), 1.0); } }` // Junta rejillas en una malla: pos(3) normal(3) uv(2). class Shape { constructor() { this.v = []; this.i = [] } grid(cols, rows, at) { const base = this.v.length / 8 for (let c = 0; c <= cols; c++) for (let r = 0; r <= rows; r++) this.v.push(...at(c, r)) for (let c = 0; c < cols; c++) for (let r = 0; r < rows; r++) { const a = base + c * (rows + 1) + r, b = a + rows + 1 this.i.push(a, a + 1, b, b, a + 1, b + 1) } return this } box(x0, x1, y0, y1, z0, z1) { const quad = (o, a, b, n) => this.grid(1, 1, (c, r) => [o[0] + a[0] * c + b[0] * r, o[1] + a[1] * c + b[1] * r, o[2] + a[2] * c + b[2] * r, ...n, c, r]) const dx = x1 - x0, dy = y1 - y0, dz = z1 - z0 quad([x0, y0, z1], [dx, 0, 0], [0, dy, 0], [0, 0, 1]) quad([x0, y0, z0], [dx, 0, 0], [0, 0, dz], [0, -1, 0]) quad([x0, y1, z0], [dx, 0, 0], [0, 0, dz], [0, 1, 0]) quad([x0, y0, z0], [0, dy, 0], [0, 0, dz], [-1, 0, 0]) quad([x1, y0, z0], [0, dy, 0], [0, 0, dz], [1, 0, 0]) return this } } const COLS = 30 const tAt = c => (c / COLS) ** 2 // más columnas junto a la costura, donde la curva es más cerrada function slope(t, open, k) { const e = 0.002, a = Math.min(1, t + e), b = Math.max(0, t - e); return (surface(a, open, k) - surface(b, open, k)) / (a - b) } // Una mitad del libro. side: -1 izquierda, 1 derecha. width: lo que ya se ha extendido, de 0 a 1. function pageShape(side, W, open, k = 1, width = 1) { return new Shape().grid(COLS, 1, (c, r) => { const t = tAt(c), n = norm([(-side * slope(t, open, k)) / W, 0, 1]) return [side * W * width * t, 0.5 - r, surface(t, open, k), ...n, side > 0 ? t : 1 - t, r] }) } // Los cantos de esa mitad, extruidos desde la misma curva: abajo, arriba y por fuera. function edgeShape(sh, side, W, open, k = 1, width = 1) { const RINGS = 5, v = r => (r >= RINGS ? 1 : r / RINGS) const z = (t, r) => (r > RINGS ? BOOK.BOARD : sheetZ(t, v(r), open, k)) for (const [y, ny] of [[-0.5, -1], [0.5, 1]]) sh.grid(COLS, RINGS + 1, (c, r) => { const t = tAt(c); return [side * W * width * t, y, z(t, r), 0, ny, 0, t, v(r)] }) sh.grid(1, RINGS + 1, (c, r) => [side * W * width, c - 0.5, z(1, r), side, 0, 0, c, v(r)]) return sh } function spineShape(outward, top, size) { const STEPS = 10, mid = top / 2 return new Shape().grid(STEPS, 1, (c, r) => { const a = (c / STEPS) * Math.PI, sx = Math.sin(a), cz = Math.cos(a) return [outward * size * sx, 0.5 + BOOK.OVER - r * (1 + 2 * BOOK.OVER), mid + mid * cz, outward * sx, 0, cz, c / STEPS, r] }) } // La hoja que gira y, en la cubierta, su grosor por el borde de abajo y por fuera. function sheetShape(pts, y0 = -0.5, y1 = 0.5) { const s = new Shape(), n = pts.length - 1 s.grid(n, 1, (c, r) => { const p = pts[c]; return [p.x, y1 + (y0 - y1) * r, p.z, p.nx, 0, p.nz, p.u, r] }) return s } function rimShape(pts, y0, y1, thick) { const s = new Shape(), n = pts.length - 1, e = pts[n] s.grid(n, 1, (c, r) => { const p = pts[c]; return [p.x - p.nx * thick * r, y0, p.z - p.nz * thick * r, 0, -1, 0, 0, 0] }) s.grid(1, 1, (c, r) => [e.x - e.nx * thick * r, y0 + (y1 - y0) * c, e.z - e.nz * thick * r, e.nz * Math.sign(e.x || 1), 0, -e.nx * Math.sign(e.x || 1), 0, 0]) return s } // El sensor: los ejes de la pantalla a partir de la orientación del dispositivo. La inclinación se desvanece con el tiempo. // MAX_DEG: lo que hay que girar el dispositivo para llegar al tope. ORBIT_*: cuánto rodea entonces la cámara al libro, en grados. const MAX_DEG = 15, FADE_S = 2.5, QUIET = 0.6, ORBIT_TILT = 7, ORBIT_YAW = 10, ORBIT_BACK = 0.06 function createTilt(onChange) { const target = { x: 0, y: 0 } let listening = false, gotOrientation = false, lastT = 0, lastOri = null, lastFade = 0, askOnTouch = null const clamp = v => Math.max(-MAX_DEG, Math.min(MAX_DEG, v)) function screenAngle() { let a = (((window.orientation ?? screen.orientation?.angle ?? 0) % 360) + 360) % 360 const landscape = innerWidth > innerHeight if (landscape && a % 180 === 0) a = 90 if (!landscape && a % 180) a = 0 return (a * Math.PI) / 180 } function add(dx, dy) { const a = screenAngle(), c = Math.cos(a), s = Math.sin(a) target.x = clamp(target.x + dx * c - dy * s) target.y = clamp(target.y + dx * s + dy * c) lastFade = lastFade || performance.now() onChange() } const stepOf = t => { const dt = Math.min(0.1, Math.max(0, (t - lastT) / 1000)); lastT = t; return dt } const quiet = v => (Math.abs(v) < QUIET ? 0 : v) function onOrientation(e) { if (e.beta == null || e.gamma == null) return gotOrientation = true const dt = stepOf(e.timeStamp) if (lastOri) { const dx = e.beta - lastOri.b, dy = e.gamma - lastOri.g const still = dt > 0 && Math.hypot(dx, dy) / dt < QUIET if (!still && Math.abs(dx) < 25 && Math.abs(dy) < 25) add(dx, dy) } lastOri = { b: e.beta, g: e.gamma } } function onMotion(e) { const r = e.rotationRate if (gotOrientation || r?.alpha == null || r?.beta == null) return const dt = stepOf(e.timeStamp) add(quiet(r.alpha) * dt, quiet(r.beta) * dt) // especificación: alpha es el eje x y beta el eje y } function listen(on) { if (on === listening) return listening = on const method = on ? 'addEventListener' : 'removeEventListener' window[method]('devicemotion', onMotion) window[method]('deviceorientation', onOrientation) target.x = target.y = 0 lastT = performance.now() lastOri = null onChange() } const forget = () => { if (askOnTouch) document.removeEventListener('click', askOnTouch); askOnTouch = null } // iOS pide permiso desde un toque. Con el permiso ya dado se repite sin toque; si no, espera al siguiente. Sin permiso, el libro queda quieto. function permit() { if (!isSecureContext) return const M = window.DeviceMotionEvent, O = window.DeviceOrientationEvent const asked = M?.requestPermission ? M.requestPermission() : O?.requestPermission ? O.requestPermission() : null if (!asked) return listen(true) asked.then(answer => { forget(); if (answer === 'granted' && document.visibilityState === 'visible') listen(true) }, () => { if (askOnTouch) return askOnTouch = () => { forget(); permit() } document.addEventListener('click', askOnTouch) }) } return { target, permit, listen, get listening() { return listening }, // Con el dispositivo quieto pueden dejar de llegar lecturas: el regreso al centro va por tiempo. fade(now) { const dt = Math.min(0.1, Math.max(0, (now - lastFade) / 1000)), keep = Math.exp(-dt / FADE_S) lastFade = now for (const k of ['x', 'y']) target[k] = Math.abs(target[k] * keep) < 0.3 ? 0 : target[k] * keep }, stop() { forget(); listen(false) }, } } export function createBook3D(container, opts) { const { total, src, whenReady, spreadOf, isWide, still = false } = opts const canvas = document.createElement('canvas') canvas.className = 'b3dcanvas' const gl = canvas.getContext('webgl2', { alpha: false, antialias: true, depth: true, powerPreference: 'high-performance' }) if (!gl) return null container.replaceChildren(canvas) let dead = false, raf = 0, u = {}, prog = null, wood = null, tableMesh = null, aniso = null, maxSize = 2400 let tableTex = null, tableAspect = 1 let backTex = null, backColors = null, backSent = '', backFrom = null, backNear = false, backTimer = 0 let W = 0, coverColor = [0.2, 0.14, 0.1], view = null, statics = null, staticKey = '', dyn = null let anim = null, camNow = null, camMove = null, zoom = { s: 1, x: 0, y: 0 }, fxM = filterMatrix('') const textures = new Map(), loading = new Map(), queue = [] const fitCache = new Map() let uploadTimer = 0, rect = { width: 1, height: 1 } const tiltNow = { x: 0, y: 0 } const tilt = createTilt(() => draw()) function makeMesh(sh, usage = gl.STATIC_DRAW) { const vao = gl.createVertexArray(), vb = gl.createBuffer(), ib = gl.createBuffer() gl.bindVertexArray(vao) gl.bindBuffer(gl.ARRAY_BUFFER, vb) gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(sh.v), usage) gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ib) gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(sh.i), usage) gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 32, 0) gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 32, 12) gl.enableVertexAttribArray(2); gl.vertexAttribPointer(2, 2, gl.FLOAT, false, 32, 24) gl.bindVertexArray(null) return { vao, vb, ib, n: sh.i.length } } const freeMesh = m => { if (m) { gl.deleteVertexArray(m.vao); gl.deleteBuffer(m.vb); gl.deleteBuffer(m.ib) } } const drawMesh = m => { gl.bindVertexArray(m.vao); gl.drawElements(gl.TRIANGLES, m.n, gl.UNSIGNED_SHORT, 0) } // Mallas que cambian en cada fotograma de una animación: se reutilizan los mismos búferes. function dynMesh(name, sh) { const m = (dyn[name] ||= makeMesh(new Shape(), gl.DYNAMIC_DRAW)) gl.bindVertexArray(m.vao) gl.bindBuffer(gl.ARRAY_BUFFER, m.vb) gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(sh.v), gl.DYNAMIC_DRAW) gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, m.ib) gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(sh.i), gl.DYNAMIC_DRAW) gl.bindVertexArray(null) m.n = sh.i.length return m } function initGL() { const shader = (type, text) => { const s = gl.createShader(type); gl.shaderSource(s, text); gl.compileShader(s); return s } prog = gl.createProgram() gl.attachShader(prog, shader(gl.VERTEX_SHADER, VS)) gl.attachShader(prog, shader(gl.FRAGMENT_SHADER, FS)) gl.linkProgram(prog) if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog) || 'shader') gl.useProgram(prog) u = {} for (const n of ['uM', 'uKind', 'uTex', 'uColor', 'uMap', 'uRect', 'uSoft', 'uAlpha', 'uSeamAt', 'uHas', 'uFlip', 'uMirror', 'uCast', 'uTab', 'uFx', 'uFxO']) u[n] = gl.getUniformLocation(prog, n) gl.uniform1i(u.uTex, 0) gl.uniform1f(u.uFlip, 1) gl.enable(gl.DEPTH_TEST) gl.depthFunc(gl.LEQUAL) gl.clearColor(0.29, 0.18, 0.1, 1) gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA) aniso = gl.getExtension('EXT_texture_filter_anisotropic') maxSize = Math.min(gl.getParameter(gl.MAX_TEXTURE_SIZE), 2400) // más grande no se aprecia y agota la memoria en la tableta wood = gl.createTexture() gl.bindTexture(gl.TEXTURE_2D, wood) gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, woodCanvas()) gl.generateMipmap(gl.TEXTURE_2D) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT) const TABLE = 14, TILE = 1.7 tableMesh = makeMesh(new Shape().grid(1, 1, (c, r) => { const x = (c * 2 - 1) * TABLE, y = (r * 2 - 1) * TABLE; return [x, y, 0, 0, 0, 1, x / TILE, y / TILE] })) dyn = {} statics = null staticKey = '' } // ----- Texturas ----- function makeTexture(source) { const tex = gl.createTexture() gl.bindTexture(gl.TEXTURE_2D, tex) gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source) gl.generateMipmap(gl.TEXTURE_2D) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) 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) if (aniso) gl.texParameterf(gl.TEXTURE_2D, aniso.TEXTURE_MAX_ANISOTROPY_EXT, Math.min(8, gl.getParameter(aniso.MAX_TEXTURE_MAX_ANISOTROPY_EXT))) return tex } // Fondo elegido por el usuario: una sola imagen fija sobre la mesa, que llena lo que se ve. Si asoma el borde, sigue en espejo, sin corte. function loadTable() { if (!opts.table) return const im = new Image() im.onload = () => { if (dead || gl.isContextLost() || !im.naturalWidth) return tableTex = makeTexture(im) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.MIRRORED_REPEAT) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT) tableAspect = im.naturalWidth / im.naturalHeight draw() } im.src = opts.table } function tableScale() { let half = 1.5 if (W > 0) { const ref = fitOf({ kind: 'open', focus: rect.height > rect.width ? 1 : 0 }) // la vista más abierta de esta pantalla const hy = ref.dist * 1.3 * Math.tan(FOV / 2) * 1.1 + Math.abs(ref.target[1]), hx = hy * (rect.width / rect.height) + Math.abs(ref.target[0]) half = Math.max(hy, hx / tableAspect) } return 1 / (2 * half) } // La contraportada: los colores de la portada, en espejo y muy difuminados para que continúe por el lomo, con un marco de su color de acento. function buildBack(im) { try { const small = Object.assign(document.createElement('canvas'), { width: 48, height: 72 }), sg = small.getContext('2d', { willReadFrequently: true }) sg.drawImage(im, 0, 0, 48, 72) const { base, accent } = coverPalette(sg.getImageData(0, 0, 48, 72).data) const H = 1152, w = Math.round(H * Math.min(0.85, Math.max(0.45, im.naturalWidth / im.naturalHeight))) const c = Object.assign(document.createElement('canvas'), { width: w, height: H }), g = c.getContext('2d', { willReadFrequently: true }) g.fillStyle = css(base) g.fillRect(0, 0, w, H) const blur = Object.assign(document.createElement('canvas'), { width: 3, height: 4 }), bg = blur.getContext('2d') bg.translate(3, 0) bg.scale(-1, 1) bg.drawImage(small, 0, 0, 3, 4) g.imageSmoothingEnabled = true g.imageSmoothingQuality = 'high' g.globalAlpha = 0.4 g.drawImage(blur, 0, 0, w, H) g.globalAlpha = 1 const shade = g.createRadialGradient(w / 2, H / 2, H * 0.2, w / 2, H / 2, H * 0.72) shade.addColorStop(0, 'rgba(0,0,0,0)') shade.addColorStop(1, 'rgba(0,0,0,.28)') g.fillStyle = shade g.fillRect(0, 0, w, H) const inset = w * 0.055 g.strokeStyle = css(accent) g.lineWidth = w * 0.009 g.strokeRect(inset, inset, w - 2 * inset, H - 2 * inset) g.lineWidth = w * 0.003 g.strokeRect(inset * 1.35, inset * 1.35, w - 2.7 * inset, H - 2.7 * inset) const mid = g.getImageData(Math.round(w * 0.2), Math.round(H * 0.3), Math.round(w * 0.6), Math.round(H * 0.4)).data, surface = [0, 0, 0] for (let p = 0; p < mid.length; p += 4) for (let k = 0; k < 3; k++) surface[k] += mid[p + k] const area = mid.length / 4 for (let k = 0; k < 3; k++) surface[k] /= area * 255 if (backTex) gl.deleteTexture(backTex) backTex = makeTexture(c) backColors = { surface: css(surface), ink: css(inkOn(surface)), accent: css(accent), accentInk: css(inkOn(accent)), line: css(mixColor(surface, inkOn(surface), 0.45)) } backSent = '' } catch (err) { console.warn('Contraportada:', err) } // sin ella, la tapa queda del color del borde de la portada } // Se fabrica una vez y solo cerca del final: al abrir el capítulo no debe retrasar las primeras páginas. function ensureBack() { if (!backFrom || !backNear || dead || gl.isContextLost()) return const im = backFrom backFrom = null buildBack(im) realBack() draw() } // La contraportada original del tomo, si existe, sustituye a la fabricada; los colores de los botones no cambian. let backImage = null, backAsked = false function realBack() { if (backImage) return placeRealBack() if (!opts.back || backAsked) return backAsked = true const im = new Image() im.onload = () => { if (dead || !im.naturalWidth) return backImage = im placeRealBack() draw() } im.src = opts.back } function placeRealBack() { if (dead || gl.isContextLost() || !backColors) return let source = backImage const big = Math.max(source.naturalWidth, source.naturalHeight) if (big > maxSize) { const k = maxSize / big, c = Object.assign(document.createElement('canvas'), { width: Math.round(source.naturalWidth * k), height: Math.round(source.naturalHeight * k) }) c.getContext('2d').drawImage(source, 0, 0, c.width, c.height) source = c } if (backTex) gl.deleteTexture(backTex) backTex = makeTexture(source) } function upload(i, im) { let source = im, w = im.naturalWidth, h = im.naturalHeight if (!w || !h) return if (Math.max(w, h) > maxSize) { // reducir aquí: pasarla entera podría fallar const k = maxSize / Math.max(w, h), c = Object.assign(document.createElement('canvas'), { width: Math.round(w * k), height: Math.round(h * k) }), g = c.getContext('2d') g.imageSmoothingQuality = 'high' g.drawImage(im, 0, 0, c.width, c.height) source = c } if (i === 0) { coverColor = sampleCover(im); backFrom = im; ensureBack() } const tex = makeTexture(source) const had = textures.get(i) if (had) gl.deleteTexture(had.tex) textures.set(i, { tex, w, h }) // La forma del libro la da la primera página que no sea ancha. if (!W && w < h) { W = Math.min(0.85, Math.max(0.45, w / h)); staticKey = ''; fitCache.clear() } draw() } function sampleCover(im) { try { const c = Object.assign(document.createElement('canvas'), { width: 8, height: 8 }), g = c.getContext('2d', { willReadFrequently: true }) g.drawImage(im, 0, im.naturalHeight * 0.92, im.naturalWidth, im.naturalHeight * 0.08, 0, 0, 8, 8) // el canto de abajo, que es lo que se ve const d = g.getImageData(0, 0, 8, 8).data, sum = [0, 0, 0] for (let p = 0; p < d.length; p += 4) for (let k = 0; k < 3; k++) sum[k] += d[p + k] return sum.map(v => (v / 64 / 255) * 0.62) } catch { return coverColor } } // Se sube de una en una y lo que se ve ahora primero: subir una textura grande para un momento el hilo principal. function pump() { uploadTimer = 0 if (dead || !queue.length || gl.isContextLost()) return if (anim && !anim.dragging && anim.p < 1) { uploadTimer = setTimeout(pump, 120); return } // no durante un giro const visible = view ? pagesOf(view) : [] const at = queue.findIndex(q => visible.includes(q.i)) const [next] = queue.splice(Math.max(0, at), 1) if (!textures.has(next.i) && wanted(next.i)) upload(next.i, next.im) if (queue.length) uploadTimer = setTimeout(pump, visible.includes(queue[0]?.i) ? 0 : 90) } let keepFrom = 0, keepTo = 0 let glEpoch = 0 // lo que se pidió antes de perder el contexto ya no vale: su textura nacería muerta y la página quedaría en blanco const wanted = i => i === 0 || (i >= keepFrom && i <= keepTo) function want(i) { if (i == null || i < 0 || i >= total || textures.has(i) || loading.has(i) || gl.isContextLost()) return if (queue.some(q => q.i === i)) return loading.set(i, true) const epoch = glEpoch const go = url => { const im = new Image() im.onload = async () => { await im.decode?.().catch(() => {}) if (dead || epoch !== glEpoch) return loading.delete(i) queue.push({ i, im }) if (!uploadTimer) uploadTimer = setTimeout(pump, 0) } im.onerror = () => { if (epoch === glEpoch) loading.delete(i) } im.src = url } if (whenReady) whenReady(i, go); else go(src(i)) } function trim() { for (const [i, t] of textures) if (!wanted(i)) { gl.deleteTexture(t.tex); textures.delete(i) } for (let n = queue.length - 1; n >= 0; n--) if (!wanted(queue[n].i)) queue.splice(n, 1) } // ----- Lo que se ve ----- const rtl = () => opts.dir() === 'rtl' function viewOf(group) { const first = group[0], spread = spreadOf(first) if (spread.length === 1 && isWide(first)) return { kind: 'wide', page: first, focus: 0, key: `w${first}` } if (spread.length === 1 && first === 0) return { kind: 'closed', under: rtl() ? -1 : 1, cover: 0, focus: 0, key: 'c' } const [a, b = null] = spread, right = rtl() ? a : b, left = rtl() ? b : a return { kind: 'open', right, left, focus: group.length === 1 && !(spread.length === 1 && opts.double?.()) ? (group[0] === right ? 1 : -1) : 0, key: `o${a}` } // a doble página, una suelta se ve con el libro entero } const pagesOf = v => (v.kind === 'closed' ? [0] : v.kind === 'wide' ? [v.page] : [v.left, v.right]).filter(i => i != null) const pageOn = (v, side) => (v.kind === 'wide' ? { i: v.page, wide: true } : v.kind === 'closed' ? { i: null } : { i: side > 0 ? v.right : v.left }) const hasAll = v => pagesOf(v).every(i => textures.has(i)) // Lo que debe llenar la pantalla. A una página, solo esa mitad; la otra sigue ahí, fuera de cuadro. function frameOf(v) { const O = BOOK.OVER, y0 = -0.5 - O, y1 = 0.5 + O, h = BOOK.BOARD + BOOK.THICK + BOOK.CROWN let x0 = -W - O, x1 = W + O if (v.kind === 'closed') { x0 = v.under > 0 ? -BOOK.SPINE : -W - O x1 = v.under > 0 ? W + O : BOOK.SPINE } else if (v.focus) { x0 = v.focus > 0 ? 0 : -W - O x1 = v.focus > 0 ? W + O : 0 } return [[x0, y0, 0], [x1, y0, 0], [x0, y1, 0], [x1, y1, 0], [x0, y0, h], [x1, y0, h], [x0, y1, h], [x1, y1, h]] } // El encuadre se calcula una vez por vista y tamaño de pantalla: no hace falta repetirlo en cada fotograma. function fitOf(v) { const margin = Math.max(14, Math.min(40, Math.min(rect.width, rect.height) * 0.04)) const key = `${v.kind}:${v.under || 0}:${v.focus}:${W}:${rect.width}x${rect.height}` let cam = fitCache.get(key) if (!cam) { cam = fitCamera(frameOf(v), rect.width / rect.height, margin / rect.width, margin / rect.height) if (fitCache.size > 12) fitCache.clear() fitCache.set(key, cam) } return cam } const mixCam = (a, b, t) => ({ target: a.target.map((n, k) => n + (b.target[k] - n) * t), dist: a.dist + (b.dist - a.dist) * t }) // Libro en reposo: la malla se reutiliza. Durante la cubierta se rehace en cada fotograma. function bookShapes(o, under, land) { const O = BOOK.OVER, ed = new Shape() const out = { left: null, right: null } for (const side of [-1, 1]) { const isLand = under && side === -under, k = isLand ? land.k : 1, width = isLand ? land.width : 1 if (!width) continue out[side > 0 ? 'right' : 'left'] = pageShape(side, W, o, k, width) edgeShape(ed, side, W, o, k, width) } const landOf = side => (under && side === -under ? land.width : 1) const xL = -W * landOf(-1) - (landOf(-1) ? O : 0), xR = W * landOf(1) + (landOf(1) ? O : 0) out.edges = ed out.board = new Shape().box(xL, xR, -0.5 - O, 0.5 + O, 0, BOOK.BOARD) return out } function staticMeshes(v) { const key = `${v.kind === 'closed' ? `c${v.under}` : 'o'}:${W}` if (key === staticKey && statics) return statics if (statics) for (const m of Object.values(statics)) freeMesh(m) staticKey = key const sh = v.kind === 'closed' ? bookShapes(0, v.under, { k: 0, width: 0 }) : bookShapes(1, 0, null) statics = {} for (const [n, s] of Object.entries(sh)) if (s) statics[n] = makeMesh(s) return statics } function pageMap(i, side, wide) { const t = textures.get(i) if (!t) return null const region = wide ? 2 * W : W, a = t.w / t.h let sx = 1, sy = 1 // Fracción del hueco que llena la imagen, sin deformarla if (a < region) sx = a / region; else sy = region / a const ox = (1 - sx) / 2, oy = (1 - sy) / 2 const scale = wide ? 0.5 : 1, start = wide && side > 0 ? 0.5 : 0 return { tex: t.tex, map: [scale / sx, (start - ox) / sx, 1 / sy, -oy / sy] } } function drawPage(mesh, page, side, mirror = false) { const p = page?.i == null ? null : pageMap(page.i, side, page.wide) gl.uniform1i(u.uKind, 4) gl.uniform1f(u.uSeamAt, side > 0 ? 0 : 1) gl.uniform1f(u.uHas, p ? 1 : 0) gl.uniform1f(u.uMirror, mirror ? 1 : 0) if (p) { gl.bindTexture(gl.TEXTURE_2D, p.tex); gl.uniform4fv(u.uMap, p.map) } gl.uniform3fv(u.uColor, opts.paper()) drawMesh(mesh) gl.uniform1f(u.uMirror, 0) } // Dibuja las dos caras de una hoja: la de delante en su lado y la de detrás, ya volteada, en el otro. function drawSheet(mesh, side, front, back, lid = null) { gl.enable(gl.CULL_FACE) gl.frontFace(side > 0 ? gl.CCW : gl.CW) gl.cullFace(gl.BACK) if (lid) { gl.uniform1i(u.uKind, 5) gl.uniform1f(u.uHas, lid.tex ? 1 : 0) if (lid.tex) gl.bindTexture(gl.TEXTURE_2D, lid.tex) gl.uniform3fv(u.uColor, coverColor) drawMesh(mesh) } else drawPage(mesh, front, side) gl.cullFace(gl.FRONT) gl.uniform1f(u.uFlip, -1) drawPage(mesh, back, -side, true) gl.uniform1f(u.uFlip, 1) gl.disable(gl.CULL_FACE) } function drawBook(m, left, right) { gl.uniform1i(u.uKind, 2) gl.uniform3fv(u.uColor, coverColor) drawMesh(m.board) gl.uniform1i(u.uKind, 3) gl.uniform3fv(u.uColor, opts.paper()) drawMesh(m.edges) if (m.left) drawPage(m.left, left, -1) if (m.right) drawPage(m.right, right, 1) } // Lo que va en la tapa: la portada delante, o la contraportada por detrás del libro. El lomo toma el color de su borde. const lidOf = isBack => ({ tex: isBack ? backTex : textures.get(0)?.tex }) function drawSpine(under, top, size, lid) { gl.uniform1i(u.uKind, 6) gl.uniform1f(u.uHas, lid.tex ? 1 : 0) if (lid.tex) gl.bindTexture(gl.TEXTURE_2D, lid.tex) gl.uniform3fv(u.uColor, coverColor) gl.uniform1f(u.uSeamAt, under > 0 ? 0.01 : 0.99) drawMesh(dynMesh('spine', spineShape(-under, top, size))) } // La cubierta: una pieza rígida con grosor. a: 0 cerrada, 1 ya abierta. function drawCover(under, a, backPage, lid) { const O = BOOK.OVER const pts = sheetCurve(a, under, W, true, 12, O) pts.forEach(p => { p.u = under > 0 ? p.t : 1 - p.t }) const mesh = dynMesh('cover', sheetShape(pts, -0.5 - O, 0.5 + O)) gl.uniform1i(u.uKind, 2) gl.uniform3fv(u.uColor, coverColor) drawMesh(dynMesh('rim', rimShape(pts, -0.5 - O, 0.5 + O, BOOK.BOARD))) drawSheet(mesh, under, null, backPage, lid) return pts.at(-1) } function stepTilt(now) { tilt.fade(now) const on = !still && zoom.s === 1 const want = on ? { x: tilt.target.x / MAX_DEG, y: tilt.target.y / MAX_DEG } : { x: 0, y: 0 } let moving = !!(tilt.target.x || tilt.target.y) && on for (const k of ['x', 'y']) { const d = want[k] - tiltNow[k] if (Math.abs(d) < 0.002) tiltNow[k] = want[k] else { tiltNow[k] += d * 0.25; moving = true } } return moving } function stepAnim(now) { if (!anim) return false const dt = Math.min(0.12, (now - anim.last) / 1000) // tras un tirón, recuperar sin alargar la animación anim.last = now if (anim.dragging) { const d = anim.goal - anim.p anim.p += d * 0.35 return true } const before = anim.p anim.p = anim.back ? Math.max(0, before - dt / anim.dur) : Math.min(1, before + dt / anim.dur) if ((anim.back && anim.p <= 0) || (!anim.back && anim.p >= 1)) { finishAnim(!anim.back); return !!anim } return true } // Acaba la animación en uno de los dos extremos y deja el libro en reposo. function finishAnim(commit = true) { const a = anim if (!a) return anim = null view = commit ? a.to : a.from camNow = null camMove = null opts.onTurnEnd?.(a.ctl) a.done?.(commit) draw() } // Dónde queda la contraportada en la pantalla: el lector pone ahí los botones, y siguen al libro cuando se mueve. function reportBack(M) { if (!opts.onBack) return let at = null if (!anim && view?.back && W > 0) { const O = BOOK.OVER, far = view.under * (W + O) const q = [[0, -0.5 - O], [far, -0.5 - O], [0, 0.5 + O], [far, 0.5 + O]].map(([x, y]) => project(M, [x, y, LID_Z])) const xs = q.map(p => ((p[0] + 1) / 2) * rect.width), ys = q.map(p => ((1 - p[1]) / 2) * rect.height) at = { x: Math.min(...xs), y: Math.min(...ys), width: Math.max(...xs) - Math.min(...xs), height: Math.max(...ys) - Math.min(...ys) } } const key = at ? `${Math.round(at.x)},${Math.round(at.y)},${Math.round(at.width)},${Math.round(at.height)}` : 'off' if (key === (backSent || 'off')) return backSent = key opts.onBack(at, backColors) } function paint(now) { raf = 0 if (dead || gl.isContextLost()) return rect = container.getBoundingClientRect() if (!rect.width || !rect.height) return const dpr = Math.min(window.devicePixelRatio || 1, 3) const cw = Math.round(rect.width * dpr), ch = Math.round(rect.height * dpr) if (canvas.width !== cw || canvas.height !== ch) { canvas.width = cw; canvas.height = ch } gl.viewport(0, 0, cw, ch) gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) let again = stepTilt(now) if (stepAnim(now)) again = true const v = anim ? anim.to : view const ready = v && W > 0 const aspect = cw / ch let cam = { target: [0, 0, 0], dist: 4 } if (ready) { cam = fitOf(v) if (anim) cam = mixCam(anim.camFrom, cam, easeIO(anim.p)) else if (camMove) { const t = Math.min(1, (now - camMove.t0) / camMove.dur) cam = mixCam(camMove.from, cam, easeIO(t)) if (t >= 1) camMove = null; else again = true } camNow = cam } // La sombra de la hoja se calcula antes, porque cae también en la mesa. let castX = 0, castA = 0, cover = null, sheet = null if (ready && anim) { const e = anim.dragging ? anim.p : easeIO(anim.p) if (anim.kind === 'cover') { cover = { a: anim.opening ? e : 1 - e, under: anim.under } castX = Math.cos(cover.a * Math.PI) * cover.under * (W + BOOK.OVER) castA = Math.sin(cover.a * Math.PI) * 0.5 } else { sheet = { a: e, side: anim.side } sheet.pts = sheetCurve(e, anim.side, W) castX = sheet.pts.at(-1).x castA = Math.sin(e * Math.PI) * 0.45 } } // Una hoja en alto queda cerca de la cámara: durante el giro, esta se aleja y se pone más vertical para que quepa y no se vea torcida. const lift = cover ? Math.sin(cover.a * Math.PI) : sheet ? 0.35 * Math.sin(sheet.a * Math.PI) : 0 if (lift) cam = { target: cam.target, dist: cam.dist * (1 + 0.3 * lift) } // Al rodear el libro, la cámara se aleja un poco para que el lado que se acerca no se salga de la pantalla. const round = Math.max(Math.abs(tiltNow.x), Math.abs(tiltNow.y)) if (round) cam = { target: cam.target, dist: cam.dist * (1 + ORBIT_BACK * round) } const dTilt = ((-tiltNow.x * ORBIT_TILT - 9 * lift) * Math.PI) / 180, yaw = (-tiltNow.y * ORBIT_YAW * Math.PI) / 180 const M = cameraMatrix(cam.target, cam.dist, aspect, TILT + dTilt, yaw) // Zoom del lector y el pequeño desplazamiento del movimiento: se aplican a la imagen final, que se vuelve a dibujar nítida. const s = zoom.s, ox = s - 1 + (2 * (zoom.x + tiltNow.y * 3)) / rect.width, oy = -(s - 1) - (2 * (zoom.y + tiltNow.x * 3)) / rect.height for (let c = 0; c < 4; c++) { M[c * 4] = s * M[c * 4] + ox * M[c * 4 + 3] M[c * 4 + 1] = s * M[c * 4 + 1] + oy * M[c * 4 + 3] } gl.uniformMatrix4fv(u.uM, false, M) reportBack(M) gl.uniformMatrix3fv(u.uFx, false, [fxM[0][0], fxM[1][0], fxM[2][0], fxM[0][1], fxM[1][1], fxM[2][1], fxM[0][2], fxM[1][2], fxM[2][2]]) gl.uniform3f(u.uFxO, fxM[0][3], fxM[1][3], fxM[2][3]) gl.uniform2f(u.uCast, castX, castA) // Mesa gl.depthMask(true) gl.uniform1i(u.uKind, 0) gl.bindTexture(gl.TEXTURE_2D, tableTex || wood) if (tableTex) { const ts = tableScale(); gl.uniform3f(u.uTab, ts / tableAspect, ts, 1) } else gl.uniform3f(u.uTab, 0, 0, 0) gl.uniform4f(u.uRect, cam.target[0], cam.target[1], Math.max(W, 0.7) * 1.3, 1) drawMesh(tableMesh) if (!ready) { if (again) draw(); return } // Sombras en la mesa: una muy pegada une el libro con la madera, la otra cae a un lado y abajo. const O = BOOK.OVER let bx0 = -W - O, bx1 = W + O if (cover || v.kind === 'closed') { const under = cover ? cover.under : v.under, reach = cover ? landWidth(cover.a) : 0 if (under > 0) { bx0 = -(W + O) * reach - BOOK.SPINE * (1 - reach); bx1 = W + O } else { bx0 = -W - O; bx1 = (W + O) * reach + BOOK.SPINE * (1 - reach) } } gl.enable(gl.BLEND) gl.depthMask(false) gl.disable(gl.DEPTH_TEST) gl.uniform1i(u.uKind, 1) for (const [dx, dy, soft, alpha] of [[-0.05, -0.062, 0.1, 0.5], [-0.02, -0.026, 0.04, 0.58], [-0.003, -0.004, 0.012, 0.75]]) { gl.uniform4f(u.uRect, bx0 + dx, -0.5 - O + dy, bx1 + dx, 0.5 + O + dy) gl.uniform1f(u.uSoft, soft) gl.uniform1f(u.uAlpha, alpha) drawMesh(tableMesh) } gl.disable(gl.BLEND) gl.enable(gl.DEPTH_TEST) gl.depthMask(true) // Libro if (cover) { // Durante la apertura, la mitad sobre la que aterriza se extiende justo debajo de la cubierta. const openV = anim.opening ? anim.to : anim.from, a = cover.a, o = smooth(0, 1, a) const width = landWidth(a), land = { k: smooth(0.97, 1, a), width } const sh = bookShapes(o, cover.under, land), m = {} for (const [n, q] of Object.entries(sh)) if (q) m[n] = dynMesh(`b_${n}`, q) const under = pageOn(openV, cover.under) const pages = { [cover.under]: under, [-cover.under]: { i: null } } drawBook(m, pages[-1], pages[1]) const size = BOOK.SPINE * Math.max(0, 1 - a * 2.5) const lid = lidOf(anim.from.back || anim.to.back) if (size > 0) drawSpine(cover.under, LID_Z, size, lid) drawCover(cover.under, a, pageOn(openV, -cover.under), lid) } else if (sheet) { // La hoja lleva por delante la página vieja de su lado y por detrás la nueva del otro. Debajo ya se ve la nueva de su lado. const m = staticMeshes(v), s0 = sheet.side const pages = { [s0]: pageOn(anim.to, s0), [-s0]: pageOn(anim.from, -s0) } drawBook(m, pages[-1], pages[1]) sheet.pts.forEach(p => { p.u = s0 > 0 ? p.t : 1 - p.t }) gl.uniform2f(u.uCast, 0, 0) drawSheet(dynMesh('sheet', sheetShape(sheet.pts)), s0, pageOn(anim.from, s0), pageOn(anim.to, -s0)) } else if (v.kind === 'closed') { drawBook(staticMeshes(v), null, null) const lid = lidOf(v.back) drawSpine(v.under, LID_Z, BOOK.SPINE, lid) drawCover(v.under, 0, { i: null }, lid) } else { drawBook(staticMeshes(v), pageOn(v, -1), pageOn(v, 1)) } if (again) draw() } function draw() { if (!raf && !dead) raf = requestAnimationFrame(paint) } function startTurn(from, to, dir, drag) { const fwd = dir > 0, r = rtl() const ctl = { anims: [{ cancel() {} }], range: Math.max(200, rect.width * (from.focus || to.focus ? 0.9 : 0.5)) } const shut = from.kind === 'closed' ? from : to.kind === 'closed' ? to : null, cover = !!shut anim = { ctl, from, to, p: 0, goal: 0, back: false, dragging: !!drag, last: performance.now(), camFrom: camNow || fitOf(from), kind: cover ? 'cover' : 'sheet', dur: cover ? 1.3 : 0.75, opening: to.kind !== 'closed', under: shut?.under ?? (r ? -1 : 1), side: fwd === r ? -1 : 1, // lado del que sale la hoja: en der → izq, avanzar la levanta de la izquierda } ctl.cleanup = () => { if (anim?.ctl === ctl) finishAnim(!anim.back) } if (drag) { ctl.move = q => { if (anim?.ctl === ctl) { anim.goal = Math.max(0, Math.min(1, q)); draw() } } ctl.release = (commit, done) => { if (anim?.ctl !== ctl) return done?.(commit) anim.dragging = false anim.back = !commit anim.done = done anim.dur *= 0.7 // Lo arrastrado ya es lineal: se pasa a la curva de la animación sin saltos. anim.p = inverseEase(anim.p) anim.last = performance.now() draw() } } return ctl } const inverseEase = y => (y < 0.5 ? Math.sqrt(y / 2) : 1 - Math.sqrt((1 - y) / 2)) const ro = new ResizeObserver(draw) ro.observe(container) const onVis = () => { if (document.visibilityState === 'visible') { if (!still) tilt.permit() } else tilt.listen(false) } document.addEventListener('visibilitychange', onVis) canvas.addEventListener('webglcontextlost', e => { e.preventDefault() glEpoch++ if (anim) finishAnim(true) textures.clear(); loading.clear(); queue.length = 0; statics = null; staticKey = ''; backTex = null; tableTex = null }) canvas.addEventListener('webglcontextrestored', () => { initGL() loadTable() want(0) if (view) for (const i of pagesOf(view)) want(i) draw() }) initGL() loadTable() if (!still) tilt.permit() const api = { // Devuelve el control del giro si lo hay: el lector lo usa para el arrastre y para acabarlo antes de tiempo. show(group, dir = 0, drag = false) { if (!group.length) return null if (anim) finishAnim(!anim.back) const next = viewOf(group), first = group[0], last = group.at(-1) keepFrom = first - 3 keepTo = last + 4 pagesOf(next).forEach(want) want(0) // la portada da el color de la tapa y de la contraportada, aunque se empiece a mitad de capítulo if (!backNear && last + 4 >= total - 1) { backNear = true; clearTimeout(backTimer); backTimer = setTimeout(ensureBack, 1200) } // cuando acabe el giro for (let k = 1; k <= 3; k++) { want(last + k); want(first - k) } trim() const prev = view let ctl = null if (prev && dir && prev.key !== next.key && !(prev.kind === 'closed' && next.kind === 'closed') && W > 0 && hasAll(next)) ctl = startTurn(prev, next, dir, drag) else if (prev && dir && prev.key === next.key && prev.focus !== next.focus && W > 0 && camNow) camMove = { from: camNow, t0: performance.now(), dur: 450 } // la otra cara de la misma pareja if (ctl) anim.to = next else view = next draw() return ctl }, // Fin del capítulo: la tapa de atrás se cierra sobre las hojas. El libro queda boca abajo y con el lomo al otro lado. showBack(dir = 0, drag = false) { if (anim) finishAnim(!anim.back) want(0) backNear = true ensureBack() const next = { kind: 'closed', back: true, under: rtl() ? 1 : -1, focus: 0, key: 'e' }, prev = view const ctl = prev && dir && prev.kind !== 'closed' && W > 0 && textures.has(0) ? startTurn(prev, next, dir, drag) : null if (!ctl) view = next draw() return ctl }, setZoom(s, x, y) { zoom = { s, x, y }; draw() }, setFilter(css) { fxM = filterMatrix(css); draw() }, invalidate(i) { const t = textures.get(i) if (t) { gl.deleteTexture(t.tex); textures.delete(i) } want(i) }, draw, // Para las pruebas. debug: () => ({ view: view?.key, focus: view?.focus, anim: anim && { kind: anim.kind, p: anim.p, side: anim.side, dragging: anim.dragging }, tilt: { ...tiltNow }, target: { ...tilt.target }, listening: tilt.listening, textures: [...textures.keys()], W, zoom: zoom.s, cam: camNow && { x: camNow.target[0], dist: camNow.dist } }), destroy() { dead = true cancelAnimationFrame(raf) clearTimeout(uploadTimer) clearTimeout(backTimer) ro.disconnect() tilt.stop() document.removeEventListener('visibilitychange', onVis) gl.getExtension('WEBGL_lose_context')?.loseContext() canvas.remove() }, } canvas.b3d = api // para las pruebas return api }