La hoja proyecta su sombra sobre el papel, con luz de frente

La sombra sigue la forma de la hoja y su borde se ensancha con la altura. Sin
WebGL se mantiene el degradado desde el lomo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-02 16:20:11 -06:00
co-authored by Claude Opus 5.5
parent 52e9edc214
commit a6f01b0e54
+118 -13
View File
@@ -1615,6 +1615,8 @@ async function viewReader(mangaId, chapterId, startPage) {
// --- Hoja con WebGL: una malla con la página como textura, en un solo canvas. Hace lo mismo que paperCurl // --- Hoja con WebGL: una malla con la página como textura, en un solo canvas. Hace lo mismo que paperCurl
// (que queda para cuando no hay WebGL2) sin una capa por cara, que es lo que le pesa a la GPU. // (que queda para cuando no hay WebGL2) sin una capa por cara, que es lo que le pesa a la GPU.
const LEAF_COLS = 32, LEAF_ROWS = 8 const LEAF_COLS = 32, LEAF_ROWS = 8
const LEAF_SHADOW_STEP = 2 // columnas de la hoja por cada tramo de sombra
const SHADOW_DARK = 0.95, SHADOW_SOFT = 0.4, SHADOW_FALL = 3 // oscuridad junto al papel, ancho del borde y caída con la altura
let leafGL = null, leafGLOff = false, leafTimer = 0 let leafGL = null, leafGLOff = false, leafTimer = 0
const leafTextures = new Map() // URL de la página → textura const leafTextures = new Map() // URL de la página → textura
const leafNoTexture = new Set() // las que no se pudieron subir (demasiado grandes, sin permiso…) const leafNoTexture = new Set() // las que no se pudieron subir (demasiado grandes, sin permiso…)
@@ -1687,11 +1689,60 @@ async function viewReader(mangaId, chapterId, startPage) {
} }
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuf) gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuf)
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, index, gl.STATIC_DRAW) gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, index, gl.STATIC_DRAW)
// Sombra sobre el papel: la proyección de la hoja con la luz de frente. Se pinta detrás de la hoja, sin imagen.
const shadowProg = gl.createProgram()
gl.attachShader(shadowProg, shader(gl.VERTEX_SHADER, `#version 300 es
uniform vec2 uSize;
layout(location = 0) in vec2 aPos;
layout(location = 1) in vec2 aRamp;
layout(location = 2) in float aAlpha;
out vec2 vRamp;
out float vAlpha;
void main() {
gl_Position = vec4(aPos.x / uSize.x * 2.0 - 1.0, 1.0 - aPos.y / uSize.y * 2.0, 0.95, 1.0);
vRamp = aRamp;
vAlpha = aAlpha;
}`))
gl.attachShader(shadowProg, shader(gl.FRAGMENT_SHADER, `#version 300 es
precision mediump float;
in vec2 vRamp;
in float vAlpha;
out vec4 color;
void main() {
vec2 r = vRamp * vRamp * (3.0 - 2.0 * vRamp);
color = vec4(0.0, 0.0, 0.0, vAlpha * r.x * r.y);
}`))
gl.linkProgram(shadowProg)
if (!gl.getProgramParameter(shadowProg, gl.LINK_STATUS)) { leafGLOff = true; return null }
// Cada tramo son 3×3 cuadros: el centro es la tira proyectada y el resto, su borde difuso.
const shadowSegs = LEAF_COLS / LEAF_SHADOW_STEP
const shadowVao = gl.createVertexArray(), shadowBuf = gl.createBuffer(), shadowIndex = gl.createBuffer()
gl.bindVertexArray(shadowVao)
gl.bindBuffer(gl.ARRAY_BUFFER, shadowBuf)
gl.bufferData(gl.ARRAY_BUFFER, shadowSegs * 16 * 20, gl.DYNAMIC_DRAW)
gl.enableVertexAttribArray(0)
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 20, 0)
gl.enableVertexAttribArray(1)
gl.vertexAttribPointer(1, 2, gl.FLOAT, false, 20, 8)
gl.enableVertexAttribArray(2)
gl.vertexAttribPointer(2, 1, gl.FLOAT, false, 20, 16)
const shadowIdx = new Uint16Array(shadowSegs * 54)
let m = 0
for (let q = 0; q < shadowSegs; q++) for (let j = 0; j < 3; j++) for (let k = 0; k < 3; k++) {
const v = (a, b) => q * 16 + a * 4 + b
shadowIdx.set([v(j, k), v(j, k + 1), v(j + 1, k), v(j + 1, k), v(j, k + 1), v(j + 1, k + 1)], m)
m += 6
}
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, shadowIndex)
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, shadowIdx, gl.STATIC_DRAW)
gl.bindVertexArray(null)
gl.bindBuffer(gl.ARRAY_BUFFER, posBuf)
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true) gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true)
const aniso = gl.getExtension('EXT_texture_filter_anisotropic') const aniso = gl.getExtension('EXT_texture_filter_anisotropic')
// Sin el contexto (iOS puede quitarlo con la app en segundo plano), el siguiente giro crea otro. // Sin el contexto (iOS puede quitarlo con la app en segundo plano), el siguiente giro crea otro.
canvas.addEventListener('webglcontextlost', () => { if (leafGL?.gl === gl) { leafGL = null; leafTextures.clear() } }) canvas.addEventListener('webglcontextlost', () => { if (leafGL?.gl === gl) { leafGL = null; leafTextures.clear() } })
leafGL = { canvas, gl, posBuf, uvBuf, aniso, positions: new Float32Array(count * 4), uvs: new Float32Array(count * 2), indexCount: n, leafGL = { canvas, gl, prog, posBuf, uvBuf, aniso, shadowProg, shadowVao, shadowBuf, shadowCount: m, shadowData: new Float32Array(shadowSegs * 16 * 5),
uSize: gl.getUniformLocation(shadowProg, 'uSize'), positions: new Float32Array(count * 4), uvs: new Float32Array(count * 2), indexCount: n,
uView: gl.getUniformLocation(prog, 'uView'), uPaper: gl.getUniformLocation(prog, 'uPaper'), maxSize: gl.getParameter(gl.MAX_TEXTURE_SIZE) } uView: gl.getUniformLocation(prog, 'uView'), uPaper: gl.getUniformLocation(prog, 'uPaper'), maxSize: gl.getParameter(gl.MAX_TEXTURE_SIZE) }
return leafGL return leafGL
} }
@@ -1745,7 +1796,7 @@ async function viewReader(mangaId, chapterId, startPage) {
leafTextures.clear() leafTextures.clear()
} }
// Devuelve null si no se puede (sin WebGL2 o sin textura): entonces se usa paperCurl. // Devuelve null si no se puede (sin WebGL2 o sin textura): entonces se usa paperCurl.
function glCurl(r, frontIm, backIm, spineLeft) { function glCurl(r, frontIm, backIm, spineLeft, paper = r) {
if (!initLeafGL() || leafNoTexture.has(frontIm.src) || (backIm && leafNoTexture.has(backIm.src))) return null if (!initLeafGL() || leafNoTexture.has(frontIm.src) || (backIm && leafNoTexture.has(backIm.src))) return null
const L = leafGL const L = leafGL
const { gl, canvas, positions, uvs } = L const { gl, canvas, positions, uvs } = L
@@ -1760,6 +1811,11 @@ async function viewReader(mangaId, chapterId, startPage) {
Object.assign(canvas.style, { width: `${W}px`, height: `${H}px` }) Object.assign(canvas.style, { width: `${W}px`, height: `${H}px` })
gl.viewport(0, 0, pw, ph) gl.viewport(0, 0, pw, ph)
gl.uniform3f(L.uView, W, H, parseFloat(getComputedStyle(box).perspective) || 2200) gl.uniform3f(L.uView, W, H, parseFloat(getComputedStyle(box).perspective) || 2200)
gl.useProgram(L.shadowProg)
gl.uniform2f(L.uSize, W, H)
gl.useProgram(L.prog)
const scale = pw / W
gl.scissor(Math.round(paper.l * scale), Math.round((H - paper.t - paper.h) * scale), Math.round(paper.w * scale), Math.round(paper.h * scale))
gl.uniform1i(L.uPaper, back ? 0 : 1) gl.uniform1i(L.uPaper, back ? 0 : 1)
gl.activeTexture(gl.TEXTURE0) gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, front) gl.bindTexture(gl.TEXTURE_2D, front)
@@ -1785,7 +1841,47 @@ async function viewReader(mangaId, chapterId, startPage) {
a[3] * b[0] + a[4] * b[3] + a[5] * b[6], a[3] * b[1] + a[4] * b[4] + a[5] * b[7], a[3] * b[2] + a[4] * b[5] + a[5] * b[8], a[3] * b[0] + a[4] * b[3] + a[5] * b[6], a[3] * b[1] + a[4] * b[4] + a[5] * b[7], a[3] * b[2] + a[4] * b[5] + a[5] * b[8],
a[6] * b[0] + a[7] * b[3] + a[8] * b[6], a[6] * b[1] + a[7] * b[4] + a[8] * b[7], a[6] * b[2] + a[7] * b[5] + a[8] * b[8], a[6] * b[0] + a[7] * b[3] + a[8] * b[6], a[6] * b[1] + a[7] * b[4] + a[8] * b[7], a[6] * b[2] + a[7] * b[5] + a[8] * b[8],
] ]
// Devuelve cuánto está levantada la hoja (0 a 1), para la sombra de debajo. Encadena los giros de tira en // Con la luz de frente, cada tira cae en vertical sobre el papel (x e y sin cambiar). La altura decide el borde:
// cerca del papel, nítido y oscuro; lejos, ancho y claro. Con MAX, los tramos que se solapan no oscurecen de más,
// y con la prueba de profundidad no se pinta lo que tapa la hoja.
function drawShadow(lift) {
const data = L.shadowData, col = (R + 1) * 4, strength = SHADOW_DARK * Math.min(1, lift * 4), fall = r.w * SHADOW_FALL
let n = 0
for (let c = 0; c < N; c += LEAF_SHADOW_STEP) {
const i = c * col, e = (c + LEAF_SHADOW_STEP) * col, down = R * 4
const ax = positions[i], ay = positions[i + 1], bx = positions[i + down], by = positions[i + down + 1], cx = positions[e], cy = positions[e + 1]
const len = Math.hypot(bx - ax, by - ay) || 1, vx = (bx - ax) / len, vy = (by - ay) / len
let ux = vy, uy = -vx
if ((cx - ax) * ux + (cy - ay) * uy < 0) { ux = -ux; uy = -uy }
for (let j = 0; j < 4; j++) for (let k = 0; k < 4; k++) {
const at = (j < 2 ? i : e) + (k < 2 ? 0 : down)
const height = Math.abs(positions[at + 2]), soft = 2 + height * SHADOW_SOFT
const oj = j === 0 ? -1 : j === 3 ? 1 : 0, ok = k === 0 ? -1 : k === 3 ? 1 : 0
data[n++] = positions[at] + (ux * oj + vx * ok) * soft
data[n++] = positions[at + 1] + (uy * oj + vy * ok) * soft
data[n++] = oj ? 0 : 1
data[n++] = ok ? 0 : 1
data[n++] = strength / (1 + height / fall)
}
}
gl.useProgram(L.shadowProg)
gl.bindVertexArray(L.shadowVao)
gl.bindBuffer(gl.ARRAY_BUFFER, L.shadowBuf)
gl.bufferSubData(gl.ARRAY_BUFFER, 0, data)
gl.enable(gl.SCISSOR_TEST)
gl.enable(gl.BLEND)
gl.blendEquation(gl.MAX)
gl.blendFunc(gl.ONE, gl.ONE)
gl.depthMask(false)
gl.drawElements(gl.TRIANGLES, L.shadowCount, gl.UNSIGNED_SHORT, 0)
gl.depthMask(true)
gl.disable(gl.BLEND)
gl.disable(gl.SCISSOR_TEST)
gl.bindVertexArray(null)
gl.useProgram(L.prog)
gl.bindBuffer(gl.ARRAY_BUFFER, L.posBuf)
}
// Devuelve cuánto está levantada la hoja (0 a 1). Encadena los giros de tira en
// tira como lo hacen las capas anidadas de paperCurl. // tira como lo hacen las capas anidadas de paperCurl.
function set(g, reverse, tiltZ = 0, tiltX = 0) { function set(g, reverse, tiltZ = 0, tiltX = 0) {
const a = curve.angles(g, reverse) const a = curve.angles(g, reverse)
@@ -1818,6 +1914,7 @@ async function viewReader(mangaId, chapterId, startPage) {
gl.bufferSubData(gl.ARRAY_BUFFER, 0, positions) gl.bufferSubData(gl.ARRAY_BUFFER, 0, positions)
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
gl.drawElements(gl.TRIANGLES, L.indexCount, gl.UNSIGNED_SHORT, 0) gl.drawElements(gl.TRIANGLES, L.indexCount, gl.UNSIGNED_SHORT, 0)
if (lift > 0) drawShadow(lift)
return lift return lift
} }
const pivot = y => { pivotY = y === 'bottom' ? r.h : y === 'top' ? 0 : r.h / 2 } const pivot = y => { pivotY = y === 'bottom' ? r.h : y === 'top' ? 0 : r.h / 2 }
@@ -1916,7 +2013,7 @@ async function viewReader(mangaId, chapterId, startPage) {
const rel = im => { const r = im.getBoundingClientRect(); return { l: r.left - B.left, t: r.top - B.top, w: r.width, h: r.height } } const rel = im => { const r = im.getBoundingClientRect(); return { l: r.left - B.left, t: r.top - B.top, w: r.width, h: r.height } }
const byX = imgs => [...imgs].sort((a, b) => a.getBoundingClientRect().left - b.getBoundingClientRect().left) const byX = imgs => [...imgs].sort((a, b) => a.getBoundingClientRect().left - b.getBoundingClientRect().left)
const place = (el, r) => Object.assign(el.style, { left: `${r.l}px`, top: `${r.t}px`, width: `${r.w}px`, height: `${r.h}px` }) const place = (el, r) => Object.assign(el.style, { left: `${r.l}px`, top: `${r.t}px`, width: `${r.w}px`, height: `${r.h}px` })
let rect, frontIm, backIm = null, spineLeft, reverse = false let rect, paper, frontIm, backIm = null, spineLeft, reverse = false
if (double) { if (double) {
// Doble página: gira la hoja del lado hacia el que se avanza (derecha si sign > 0); su reverso es // Doble página: gira la hoja del lado hacia el que se avanza (derecha si sign > 0); su reverso es
// la página nueva del otro lado del lomo, y la página vieja de ese lado sigue a la vista (copia // la página nueva del otro lado del lomo, y la página vieja de ese lado sigue a la vista (copia
@@ -1928,6 +2025,9 @@ async function viewReader(mangaId, chapterId, startPage) {
const front = sign > 0 ? oldX.at(-1) : oldX[0] const front = sign > 0 ? oldX.at(-1) : oldX[0]
const stay = sign > 0 ? oldX[0] : oldX.at(-1) const stay = sign > 0 ? oldX[0] : oldX.at(-1)
rect = rel(front) rect = rel(front)
const other = rel(stay)
const left = Math.min(rect.l, other.l), top = Math.min(rect.t, other.t)
paper = { l: left, t: top, w: Math.max(rect.l + rect.w, other.l + other.w) - left, h: Math.max(rect.t + rect.h, other.t + other.h) - top }
frontIm = front frontIm = front
backIm = sign > 0 ? newX[0] : newX.at(-1) backIm = sign > 0 ? newX[0] : newX.at(-1)
spineLeft = sign > 0 spineLeft = sign > 0
@@ -1949,16 +2049,21 @@ async function viewReader(mangaId, chapterId, startPage) {
reverse = dir < 0 reverse = dir < 0
if (dir > 0) old.style.visibility = 'hidden' if (dir > 0) old.style.visibility = 'hidden'
} }
// Sombra que la hoja levantada proyecta sobre lo que hay debajo.
const drop = document.createElement('i')
drop.className = `curldrop ${spineLeft ? 'l' : 'r'}`
place(drop, rect)
const grabAt = grabY != null ? (grabY - B.top - rect.t) / rect.h : 0.5 const grabAt = grabY != null ? (grabY - B.top - rect.t) / rect.h : 0.5
const grab = grabAt < 1 / 3 ? -1 : grabAt > 2 / 3 ? 1 : 0 const grab = grabAt < 1 / 3 ? -1 : grabAt > 2 / 3 ? 1 : 0
const curl = glCurl(rect, frontIm, backIm, spineLeft) || paperCurl(rect, frontIm.src, backIm?.src || null, spineLeft, !!grab) const curl = glCurl(rect, frontIm, backIm, spineLeft, paper) || paperCurl(rect, frontIm.src, backIm?.src || null, spineLeft, !!grab)
if (!curl.gl) place(curl.el, rect) // Sin WebGL, la sombra de debajo es un degradado desde el lomo.
box.append(drop, curl.el) let drop = null
extra.push(drop, curl.el) if (!curl.gl) {
place(curl.el, rect)
drop = document.createElement('i')
drop.className = `curldrop ${spineLeft ? 'l' : 'r'}`
place(drop, rect)
box.append(drop)
extra.push(drop)
}
box.append(curl.el)
extra.push(curl.el)
;[g0, g1] = double ? [0, 1] : curl.span(reverse) ;[g0, g1] = double ? [0, 1] : curl.span(reverse)
// Tocada o agarrada por arriba o por abajo (tercio superior o inferior), esa parte va por delante: la hoja se curva // Tocada o agarrada por arriba o por abajo (tercio superior o inferior), esa parte va por delante: la hoja se curva
// sin separarse del lomo. Sin capas nuevas; es 0 al principio y al final del giro, así que cae exacta. // sin separarse del lomo. Sin capas nuevas; es 0 al principio y al final del giro, así que cae exacta.
@@ -1967,7 +2072,7 @@ async function viewReader(mangaId, chapterId, startPage) {
step = g => { step = g => {
const k = grab ? grab * Math.sin(Math.PI * Math.min(1, Math.max(0, (g - g0) / (g1 - g0 || 1)))) : 0 const k = grab ? grab * Math.sin(Math.PI * Math.min(1, Math.max(0, (g - g0) / (g1 - g0 || 1)))) : 0
const lift = curl.set(g, reverse, (spineLeft ? 1 : -1) * (reverse ? -1 : 1) * GRAB_TILT_Z * k, GRAB_TILT_X * k) const lift = curl.set(g, reverse, (spineLeft ? 1 : -1) * (reverse ? -1 : 1) * GRAB_TILT_Z * k, GRAB_TILT_X * k)
drop.style.opacity = (lift * 0.8).toFixed(3) if (drop) drop.style.opacity = (lift * 0.8).toFixed(3)
} }
dur = double ? 750 : 610 dur = double ? 750 : 610
auto = t => g0 + (g1 - g0) * t auto = t => g0 + (g1 - g0) * t