Files
manga-reader/app/public/book3d.js
T
ninoandClaude Opus 5.5 429db59f5f Quitar el laboratorio del giroscopio
Ya sirvió para calibrar createTilt con grabaciones del iPad mini 6 y el Pixel 10: fuera la página #/gyrolab, su botón en
Ajustes y la ruta /api/gyrolab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 21:12:58 -06:00

1092 lines
55 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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) }
export 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)
export 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.
export 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).
export 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. Fuente principal: el giroscopio (devicemotion.rotationRate, en °/s y en los ejes del dispositivo), que no falla con
// el dispositivo vertical. La orientación del sistema (deviceorientation) solo se usa si no hay giroscopio: sus ángulos β/γ saltan
// cerca de β = 90°, aunque el dispositivo esté quieto. La inclinación acumulada vuelve sola al centro con el tiempo y se acerca al
// tope con suavidad (tanh). Al caminar, la agitación que mide el acelerómetro baja la sensibilidad.
// Calibrado con grabaciones reales del iPad mini 6 y el Pixel 10 (ver HANDOFF-LIBRO3D.md).
// MAX_DEG: lo que hay que girar el dispositivo para llegar al 76 % del tope. ORBIT_*: cuánto rodea la cámara al libro en el tope, en grados.
export const MAX_DEG = 15
const FADE_S = 2.5, QUIET = 0.6, DEAD = 1, ORBIT_TILT = 10, ORBIT_YAW = 15, ORBIT_BACK = 0.12
const SMOOTH_S = 0.058, WALK_FROM = 0.25, WALK_K = 2.5, WALK_S = 0.4
export function createTilt(onChange) {
const target = { x: 0, y: 0 }, raw = { x: 0, y: 0 }
let listening = false, gotGyro = false, lastT = 0, lastOri = null, lastFade = 0, askOnTouch = null
// iOS da la gravedad con el signo al revés que la especificación; se confirma con β en cuanto se puede.
let gSign = window.DeviceMotionEvent?.requestPermission ? -1 : 1, grav = null, land = null, shake = 0
const LIM = 2 * MAX_DEG
const lim = v => Math.max(-LIM, Math.min(LIM, v))
const soft = () => { for (const k of ['x', 'y']) target[k] = MAX_DEG * Math.tanh(raw[k] / MAX_DEG) }
// Giro de la pantalla. Safari del iPad informa mal el ángulo (90 en vertical, 180 en horizontal con el borde derecho abajo):
// la disposición dice si es vertical u horizontal y, en horizontal, la gravedad dice hacia qué lado.
function screenAngle() {
let a = (((window.orientation ?? screen.orientation?.angle ?? 0) % 360) + 360) % 360
const landscape = innerWidth > innerHeight
if (landscape) {
if (grav && Math.abs(grav[0]) > 3) land = grav[0] > 0 ? 90 : 270
a = land ?? (a % 180 ? a : 90)
} else if (a % 180) a = 0
return (a * Math.PI) / 180
}
function add(dx, dy) {
const a = screenAngle(), c = Math.cos(a), s = Math.sin(a)
raw.x = lim(raw.x + dx * c - dy * s)
raw.y = lim(raw.y + dx * s + dy * c)
soft()
lastFade = lastFade || performance.now()
onChange()
}
const stepOf = t => { const dt = Math.min(0.1, Math.max(0, (t - lastT) / 1000)); lastT = t; return dt }
function onOrientation(e) {
if (e.beta == null || e.gamma == null) return
const sb = Math.sin((e.beta * Math.PI) / 180)
if (grav && Math.abs(sb) > 0.7 && Math.abs(grav[1]) > 6) gSign *= Math.sign(grav[1] * sb) // y de la reacción a la gravedad = 9,8·sin β
if (gotGyro) return
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, g = e.accelerationIncludingGravity, l = e.acceleration
if (g?.x != null && g?.y != null) grav = [g.x * gSign, g.y * gSign]
if (r?.alpha == null || r?.beta == null) return
gotGyro = true
const dt = stepOf(e.timeStamp)
if (l?.x != null) shake += (Math.hypot(l.x, l.y || 0, l.z || 0) - shake) * Math.min(1, dt / WALK_S)
const speed = Math.hypot(r.alpha, r.beta) // especificación: alpha es el eje x y beta el eje y; gamma (girar como un volante) no cuenta
if (speed <= DEAD) return
const k = ((speed - DEAD) / speed) * dt / (1 + Math.max(0, shake - WALK_FROM) * WALK_K)
add(r.alpha * k, r.beta * k)
}
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 = raw.x = raw.y = shake = 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']) raw[k] = Math.abs(raw[k] * keep) < 0.3 ? 0 : raw[k] * keep
soft()
},
stop() { forget(); listen(false) },
}
}
// La cámara sigue a la inclinación con un suavizado por tiempo, igual a 60 que a 120 Hz. cur: { x, y } en fracción del tope.
export function followTilt(tilt, cur, now, on) {
tilt.fade(now)
const dt = Math.min(1 / 30, Math.max(0, (now - (cur.t ?? now)) / 1000)), a = 1 - Math.exp(-dt / SMOOTH_S)
cur.t = now
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] - cur[k]
if (Math.abs(d) < 0.002) cur[k] = want[k]
else { cur[k] += d * a; moving = true }
}
return moving
}
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)
}
const stepTilt = now => followTilt(tilt, tiltNow, now, !still && zoom.s === 1)
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: { x: tiltNow.x, y: tiltNow.y }, 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
}