670 lines
24 KiB
JavaScript
670 lines
24 KiB
JavaScript
import * as THREE from 'three';
|
|
import { ENTRIES } from '../data/entries.js';
|
|
import { PAGE_W, PAGE_H, HOLE_U, acquirePageTextures, releasePageTextures, getPlaceholderTextures, prefetchPageTextures, makeTwoSidedMaterial } from '../textures/pages.js';
|
|
import { clamp, easeOutCubic, easeInOutCubic } from '../utils/easing.js';
|
|
|
|
const RING_R = 0.085;
|
|
const RING_TUBE = 0.0115;
|
|
const STACK_GAP = 0.004;
|
|
const TOP_LIFT = 0.02; // 顶层左右两页高出栈内的距离
|
|
const FLIP_LIFT = 0.028; // 翻页过程中的最大抬高(略高于顶层静止高度)
|
|
const PAGE_GAP = 0.02; // 页内缘离书脊的小空隙
|
|
|
|
function makeLeatherTexture({ emblem = false } = {}) {
|
|
const w = 512, h = 640;
|
|
const c = document.createElement('canvas');
|
|
c.width = w; c.height = h;
|
|
const ctx = c.getContext('2d');
|
|
|
|
// 小牛皮:深棕色,平滑细腻的纯色
|
|
const g = ctx.createLinearGradient(0, 0, w, h);
|
|
g.addColorStop(0, '#6e4527');
|
|
g.addColorStop(0.5, '#5f3a20');
|
|
g.addColorStop(1, '#4f2f19');
|
|
ctx.fillStyle = g;
|
|
ctx.fillRect(0, 0, w, h);
|
|
|
|
// 极细腻的天然纹理(小牛皮几乎无颗粒,只有细微毛孔)
|
|
for (let i = 0; i < 16000; i++) {
|
|
const x = Math.random() * w, y = Math.random() * h;
|
|
const s = Math.random() * 1.2 + 0.3;
|
|
ctx.fillStyle = Math.random() > 0.5 ? 'rgba(28,14,6,0.06)' : 'rgba(205,165,125,0.05)';
|
|
ctx.beginPath();
|
|
ctx.ellipse(x, y, s, s * 0.7, Math.random() * Math.PI, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
|
|
// 极淡的天然色差
|
|
for (let i = 0; i < 40; i++) {
|
|
const x = Math.random() * w, y = Math.random() * h, r = Math.random() * 120 + 40;
|
|
const rg = ctx.createRadialGradient(x, y, 0, x, y, r);
|
|
rg.addColorStop(0, 'rgba(20,8,3,0.06)');
|
|
rg.addColorStop(1, 'rgba(0,0,0,0)');
|
|
ctx.fillStyle = rg;
|
|
ctx.fillRect(0, 0, w, h);
|
|
}
|
|
|
|
const tex = new THREE.CanvasTexture(c);
|
|
tex.colorSpace = THREE.SRGBColorSpace;
|
|
tex.anisotropy = 8;
|
|
return tex;
|
|
}
|
|
|
|
// 内侧绒面(粗糙、哑光、模糊绒毛)
|
|
function makeSuedeTexture() {
|
|
const w = 1024, h = 1280;
|
|
const c = document.createElement('canvas');
|
|
c.width = w; c.height = h;
|
|
const ctx = c.getContext('2d');
|
|
const g = ctx.createLinearGradient(0, 0, w, h);
|
|
g.addColorStop(0, '#a8825c');
|
|
g.addColorStop(0.5, '#96724e');
|
|
g.addColorStop(1, '#866244');
|
|
ctx.fillStyle = g;
|
|
ctx.fillRect(0, 0, w, h);
|
|
// 密集绒毛噪点(更细更密)
|
|
for (let i = 0; i < 420000; i++) {
|
|
const x = Math.random() * w, y = Math.random() * h;
|
|
ctx.fillStyle = Math.random() > 0.5 ? 'rgba(40,22,10,0.10)' : 'rgba(230,200,160,0.08)';
|
|
ctx.fillRect(x, y, Math.random() * 1.1 + 0.22, Math.random() * 1.1 + 0.22);
|
|
}
|
|
// 绒毛短划痕(更细密)
|
|
ctx.strokeStyle = 'rgba(30,16,8,0.10)';
|
|
ctx.lineWidth = 0.65;
|
|
for (let i = 0; i < 70000; i++) {
|
|
const x = Math.random() * w, y = Math.random() * h, a = Math.random() * Math.PI, l = Math.random() * 7 + 1.5;
|
|
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + Math.cos(a) * l, y + Math.sin(a) * l); ctx.stroke();
|
|
}
|
|
const edge = ctx.createRadialGradient(w / 2, h / 2, Math.min(w, h) * 0.4, w / 2, h / 2, Math.max(w, h) * 0.72);
|
|
edge.addColorStop(0, 'rgba(0,0,0,0)');
|
|
edge.addColorStop(1, 'rgba(50,28,12,0.18)');
|
|
ctx.fillStyle = edge;
|
|
ctx.fillRect(0, 0, w, h);
|
|
const tex = new THREE.CanvasTexture(c);
|
|
tex.colorSpace = THREE.SRGBColorSpace;
|
|
tex.anisotropy = 16;
|
|
return tex;
|
|
}
|
|
|
|
// 圆角矩形轮廓(直线段 + 圆弧段都用 lineTo 细分,正反面/皮边共享同一轮廓)
|
|
function roundedRectShape(w, h, r, edgeSeg = 16, arcSeg = 10) {
|
|
const hw = w / 2, hh = h / 2;
|
|
const s = new THREE.Shape();
|
|
s.moveTo(-hw + r, hh);
|
|
// 上边
|
|
for (let i = 1; i <= edgeSeg; i++) s.lineTo(-hw + r + (w - 2 * r) * i / edgeSeg, hh);
|
|
// 右上角:90° → 0°
|
|
for (let i = 1; i <= arcSeg; i++) { const a = Math.PI / 2 - (Math.PI / 2) * i / arcSeg; s.lineTo(hw - r + r * Math.cos(a), hh - r + r * Math.sin(a)); }
|
|
// 右边
|
|
for (let i = 1; i <= edgeSeg; i++) s.lineTo(hw, hh - r - (h - 2 * r) * i / edgeSeg);
|
|
// 右下角:0° → -90°
|
|
for (let i = 1; i <= arcSeg; i++) { const a = 0 - (Math.PI / 2) * i / arcSeg; s.lineTo(hw - r + r * Math.cos(a), -hh + r + r * Math.sin(a)); }
|
|
// 下边
|
|
for (let i = 1; i <= edgeSeg; i++) s.lineTo(hw - r - (w - 2 * r) * i / edgeSeg, -hh);
|
|
// 左下角:-90° → -180°
|
|
for (let i = 1; i <= arcSeg; i++) { const a = -Math.PI / 2 - (Math.PI / 2) * i / arcSeg; s.lineTo(-hw + r + r * Math.cos(a), -hh + r + r * Math.sin(a)); }
|
|
// 左边
|
|
for (let i = 1; i <= edgeSeg; i++) s.lineTo(-hw, -hh + r + (h - 2 * r) * i / edgeSeg);
|
|
// 左上角:180° → 90°
|
|
for (let i = 1; i <= arcSeg; i++) { const a = Math.PI - (Math.PI / 2) * i / arcSeg; s.lineTo(-hw + r + r * Math.cos(a), hh - r + r * Math.sin(a)); }
|
|
s.closePath();
|
|
return s;
|
|
}
|
|
|
|
// 软皮曲面:ShapeGeometry 三角化(圆角正确、无缺口)+ 书脊处弧度弯曲
|
|
function makeLeatherSurface(w, h, crXY, curve, flip) {
|
|
// 规则网格(含内部顶点,曲面平滑无对角褶痕)+ 角区收缩到圆弧
|
|
const segX = 64, segY = 48;
|
|
const hw = w / 2, hh = h / 2;
|
|
const bx = hw - crXY, by = hh - crXY;
|
|
const sigma = 0.5;
|
|
const pos = [], uvs = [], idx = [];
|
|
const g = [];
|
|
|
|
for (let j = 0; j <= segY; j++) {
|
|
g[j] = [];
|
|
const y = -hh + (j / segY) * h;
|
|
for (let i = 0; i <= segX; i++) {
|
|
const x = -hw + (i / segX) * w;
|
|
let px = x, py = y;
|
|
const ax = Math.abs(x), ay = Math.abs(y);
|
|
// 角区:外部点投影到圆弧上(内部点保留)
|
|
if (ax > bx && ay > by) {
|
|
const dx = ax - bx, dy = ay - by;
|
|
const dl = Math.sqrt(dx * dx + dy * dy);
|
|
if (dl > crXY) {
|
|
const k = crXY / dl;
|
|
px = Math.sign(x) * (bx + dx * k);
|
|
py = Math.sign(y) * (by + dy * k);
|
|
}
|
|
}
|
|
const v = py / hh;
|
|
const z = -curve * Math.exp(-(px * px) / (2 * sigma * sigma)) * (1 - 0.25 * v * v);
|
|
g[j].push(pos.length / 3);
|
|
pos.push(px, py, z);
|
|
uvs.push(flip ? (hw - px) / w : (px + hw) / w, (py + hh) / h);
|
|
}
|
|
}
|
|
|
|
for (let j = 0; j < segY; j++) {
|
|
for (let i = 0; i < segX; i++) {
|
|
const a = g[j][i], b = g[j][i + 1], c = g[j + 1][i], d = g[j + 1][i + 1];
|
|
if (flip) idx.push(a, c, b, b, c, d);
|
|
else idx.push(a, b, c, b, d, c);
|
|
}
|
|
}
|
|
|
|
const geo = new THREE.BufferGeometry();
|
|
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
|
|
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
|
|
geo.setIndex(idx);
|
|
geo.computeVertexNormals();
|
|
return geo;
|
|
}
|
|
|
|
// 皮边:与曲面共享同一圆角矩形轮廓的窄侧壁(封闭厚度,纯色)
|
|
function makeSideRim(w, h, crXY, thick, curve) {
|
|
const shape = roundedRectShape(w, h, crXY);
|
|
const pts = shape.getPoints();
|
|
const hh = h / 2, sigma = 0.5;
|
|
const pos = [], idx = [];
|
|
for (let i = 0; i < pts.length; i++) {
|
|
const px = pts[i].x, py = pts[i].y;
|
|
const v = py / hh;
|
|
const z = -curve * Math.exp(-(px * px) / (2 * sigma * sigma)) * (1 - 0.25 * v * v);
|
|
pos.push(px, py, z - thick / 2, px, py, z + thick / 2);
|
|
if (i > 0) {
|
|
const a = (i - 1) * 2, b = a + 1, c = i * 2, d = i * 2 + 1;
|
|
idx.push(a, c, b, b, c, d);
|
|
}
|
|
}
|
|
const n = pts.length;
|
|
idx.push((n - 1) * 2, 0, (n - 1) * 2 + 1, (n - 1) * 2 + 1, 0, 1);
|
|
const geo = new THREE.BufferGeometry();
|
|
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
|
|
geo.setIndex(idx);
|
|
geo.computeVertexNormals();
|
|
return geo;
|
|
}
|
|
|
|
// 皮革法线贴图:由卵石颗粒 + 皱纹 + 缝线凹槽的高度图生成
|
|
function makeLeatherNormalMap() {
|
|
const w = 512, h = 640;
|
|
const c = document.createElement('canvas');
|
|
c.width = w; c.height = h;
|
|
const ctx = c.getContext('2d', { willReadFrequently: true });
|
|
// 平面基准
|
|
ctx.fillStyle = 'rgb(128,128,128)';
|
|
ctx.fillRect(0, 0, w, h);
|
|
// 极细颗粒(光滑小牛皮:几乎无凹凸)
|
|
for (let i = 0; i < 30000; i++) {
|
|
const x = Math.random() * w, y = Math.random() * h, r = Math.random() * 1.5 + 0.4;
|
|
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
|
|
g.addColorStop(0, 'rgba(255,255,255,0.2)');
|
|
g.addColorStop(0.65, 'rgba(128,128,128,0.03)');
|
|
g.addColorStop(1, 'rgba(0,0,0,0.16)');
|
|
ctx.fillStyle = g;
|
|
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
|
|
}
|
|
// 高度图 → 法线贴图
|
|
const img = ctx.getImageData(0, 0, w, h);
|
|
const out = ctx.createImageData(w, h);
|
|
const S = 1.6;
|
|
const hAt = (yy, xx) => img.data[(Math.min(h - 1, Math.max(0, yy)) * w + Math.min(w - 1, Math.max(0, xx))) * 4];
|
|
for (let y = 0; y < h; y++) {
|
|
for (let x = 0; x < w; x++) {
|
|
const l = hAt(y, x - 1), r = hAt(y, x + 1), u = hAt(y - 1, x), d = hAt(y + 1, x);
|
|
const nx = (l - r) * S;
|
|
const ny = (u - d) * S;
|
|
const nz = 255;
|
|
const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
|
|
const o = (y * w + x) * 4;
|
|
out.data[o] = (nx / len * 0.5 + 0.5) * 255;
|
|
out.data[o + 1] = (ny / len * 0.5 + 0.5) * 255;
|
|
out.data[o + 2] = (nz / len * 0.5 + 0.5) * 255;
|
|
out.data[o + 3] = 255;
|
|
}
|
|
}
|
|
ctx.putImageData(out, 0, 0);
|
|
const tex = new THREE.CanvasTexture(c);
|
|
tex.colorSpace = THREE.NoColorSpace;
|
|
tex.anisotropy = 8;
|
|
return tex;
|
|
}
|
|
|
|
// 内侧绒面法线:密集细绒毛(高频噪点)
|
|
function makeSuedeNormalMap() {
|
|
const w = 512, h = 640;
|
|
const c = document.createElement('canvas');
|
|
c.width = w; c.height = h;
|
|
const ctx = c.getContext('2d', { willReadFrequently: true });
|
|
ctx.fillStyle = 'rgb(128,128,128)';
|
|
ctx.fillRect(0, 0, w, h);
|
|
// 密集绒毛噪点
|
|
const img = ctx.createImageData(w, h);
|
|
for (let i = 0; i < img.data.length; i += 4) {
|
|
const v = 128 + (Math.random() - 0.5) * 60;
|
|
img.data[i] = img.data[i + 1] = img.data[i + 2] = v;
|
|
img.data[i + 3] = 255;
|
|
}
|
|
ctx.putImageData(img, 0, 0);
|
|
// 高度 → 法线
|
|
const src = ctx.getImageData(0, 0, w, h);
|
|
const out = ctx.createImageData(w, h);
|
|
const S = 3.2;
|
|
const hAt = (yy, xx) => src.data[(Math.min(h - 1, Math.max(0, yy)) * w + Math.min(w - 1, Math.max(0, xx))) * 4];
|
|
for (let y = 0; y < h; y++) {
|
|
for (let x = 0; x < w; x++) {
|
|
const l = hAt(y, x - 1), r = hAt(y, x + 1), u = hAt(y - 1, x), d = hAt(y + 1, x);
|
|
const nx = (l - r) * S, ny = (u - d) * S, nz = 255;
|
|
const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
|
|
const o = (y * w + x) * 4;
|
|
out.data[o] = (nx / len * 0.5 + 0.5) * 255;
|
|
out.data[o + 1] = (ny / len * 0.5 + 0.5) * 255;
|
|
out.data[o + 2] = (nz / len * 0.5 + 0.5) * 255;
|
|
out.data[o + 3] = 255;
|
|
}
|
|
}
|
|
ctx.putImageData(out, 0, 0);
|
|
const tex = new THREE.CanvasTexture(c);
|
|
tex.colorSpace = THREE.NoColorSpace;
|
|
tex.anisotropy = 16;
|
|
return tex;
|
|
}
|
|
|
|
export class Binder {
|
|
constructor() {
|
|
this.group = new THREE.Group();
|
|
this.pages = [];
|
|
this.current = 0;
|
|
this.flip = null; // { index, from, to, t, duration }
|
|
this.resetTween = null; // 回卷重置动画
|
|
this.onChange = null;
|
|
|
|
this.suedeTex = makeSuedeTexture();
|
|
this.suedeNormalTex = makeSuedeNormalMap();
|
|
this.placeholder = getPlaceholderTextures();
|
|
this._textured = new Set();
|
|
this._buildCovers();
|
|
this._buildHingePlate();
|
|
this._buildRings();
|
|
this._buildPages();
|
|
|
|
// 微微后仰,像阅读架
|
|
this.group.rotation.x = -0.09;
|
|
}
|
|
|
|
_buildCovers() {
|
|
const coverW = PAGE_W + 0.2;
|
|
const coverH = PAGE_H + 0.2;
|
|
const spineW = 0.12;
|
|
const thick = 0.017; // 牛皮厚度
|
|
|
|
// 内侧:粗糙绒面(哑光)——外皮不渲染,只保留内侧
|
|
const innerMat = new THREE.MeshStandardMaterial({
|
|
map: this.suedeTex,
|
|
normalMap: this.suedeNormalTex,
|
|
normalScale: new THREE.Vector2(1.1, 1.1),
|
|
roughness: 0.92,
|
|
metalness: 0.0,
|
|
});
|
|
|
|
// 一张牛皮:仅内侧曲面 + 封闭皮边
|
|
const totalW = coverW * 2 + spineW;
|
|
const frontGeo = makeLeatherSurface(totalW, coverH, 0.06, 0.07, false);
|
|
const rimGeo = makeSideRim(totalW, coverH, 0.06, thick, 0.07);
|
|
const rimMat = new THREE.MeshStandardMaterial({ color: 0x6b4524, roughness: 0.7, metalness: 0.0, side: THREE.DoubleSide });
|
|
|
|
const front = new THREE.Mesh(frontGeo, innerMat); // +z 朝书页(绒面)
|
|
front.position.z = thick / 2;
|
|
const rim = new THREE.Mesh(rimGeo, rimMat);
|
|
|
|
this.cover = new THREE.Group();
|
|
this.cover.add(front, rim);
|
|
this.cover.position.set(0, 0, -0.095);
|
|
front.receiveShadow = rim.receiveShadow = true;
|
|
front.castShadow = rim.castShadow = true;
|
|
|
|
this.group.add(this.cover);
|
|
}
|
|
|
|
_buildHingePlate() {
|
|
// 金属连接片:薄薄地贴合在牛皮书脊内面上,用于连接铁环
|
|
const mat = new THREE.MeshStandardMaterial({
|
|
color: 0xd8d2c6, metalness: 0.95, roughness: 0.25,
|
|
});
|
|
// 薄片(有可见厚度的金属条),贴在牛皮内面(书脊处曲面 z≈-0.157)上
|
|
const plate = new THREE.Mesh(new THREE.BoxGeometry(0.09, 1.95, 0.04), mat);
|
|
plate.position.set(0, 0, -0.137);
|
|
plate.castShadow = true;
|
|
plate.receiveShadow = true;
|
|
|
|
// 铆钉:把薄片钉在牛皮上(覆盖新的铁环范围 ±0.896)
|
|
const rivetGeo = new THREE.SphereGeometry(0.013, 8, 6);
|
|
const rivets = [];
|
|
for (const ry of [-0.78, -0.39, 0, 0.39, 0.78]) {
|
|
const rivet = new THREE.Mesh(rivetGeo, mat);
|
|
rivet.position.set(0, ry, -0.116);
|
|
rivet.scale.z = 0.5;
|
|
rivet.castShadow = true;
|
|
rivets.push(rivet);
|
|
}
|
|
|
|
this.hingePlate = new THREE.Group();
|
|
this.hingePlate.add(plate, ...rivets);
|
|
this.group.add(this.hingePlate);
|
|
}
|
|
|
|
_buildRings() {
|
|
const mat = new THREE.MeshStandardMaterial({
|
|
color: 0xd4d7dc, metalness: 0.95, roughness: 0.2,
|
|
});
|
|
// 更平滑的活页环
|
|
const geo = new THREE.TorusGeometry(RING_R, RING_TUBE, 12, 24);
|
|
this.rings = [];
|
|
for (const u of HOLE_U) {
|
|
const y = (0.5 - u) * PAGE_H;
|
|
const ring = new THREE.Mesh(geo, mat);
|
|
ring.rotation.x = Math.PI / 2; // 环面落在 XZ 平面(绕书脊)
|
|
ring.position.set(0, y, 0);
|
|
ring.castShadow = true;
|
|
this.rings.push(ring);
|
|
this.group.add(ring);
|
|
}
|
|
}
|
|
|
|
_makePageGeometry(segX, segY) {
|
|
// 纯平面页(无自然弧度):页内缘离书脊留出小空隙,左右页不紧贴
|
|
const geo = new THREE.PlaneGeometry(PAGE_W, PAGE_H, segX, segY);
|
|
geo.translate(PAGE_W / 2 + PAGE_GAP, 0, 0);
|
|
return geo;
|
|
}
|
|
|
|
_ensureHiGeo(p) {
|
|
if (!p.hiGeo) {
|
|
p.hiGeo = this._makePageGeometry(96, 8);
|
|
p.hiBase = Float32Array.from(p.hiGeo.attributes.position.array);
|
|
}
|
|
}
|
|
|
|
_buildPages() {
|
|
// 共享极简几何(所有栈内页相同,只露边缘无需弯曲)
|
|
this.loGeo = this._makePageGeometry(1, 1);
|
|
|
|
ENTRIES.forEach((entry, i) => {
|
|
// 单 mesh + 双面材质:正面登记卡、背面素描(gl_FrontFacing 自动选择)
|
|
// 活页孔用 alphaTest 直接丢弃透明像素(不启用 transparent,避免透明+双面拆两次 draw call)
|
|
const mat = makeTwoSidedMaterial(this.placeholder.front, this.placeholder.back, {
|
|
alphaTest: 0.5,
|
|
});
|
|
const mesh = new THREE.Mesh(this.loGeo, mat); // 初始为栈内页,用极简几何
|
|
mesh.castShadow = false; // 由 _restackState 依据是否为顶层页控制
|
|
|
|
const g = new THREE.Group();
|
|
g.add(mesh);
|
|
g.userData.index = i;
|
|
g.userData.entry = entry;
|
|
|
|
this.pages.push({
|
|
group: g,
|
|
hiGeo: null, // 高分段几何延迟创建(成为顶层页时才需要弯曲)
|
|
hiBase: null,
|
|
loGeo: this.loGeo,
|
|
material: mat,
|
|
mesh,
|
|
isPlaceholder: true,
|
|
angle: 0,
|
|
y: 0,
|
|
z: 0,
|
|
targetZ: 0,
|
|
});
|
|
|
|
this.group.add(g);
|
|
});
|
|
|
|
this._restack(true);
|
|
}
|
|
|
|
// 各页休息位置:角度 + 目标 z(堆叠深度);z 由 update 里平滑动画过渡
|
|
_restack(instant = false) {
|
|
this.pages.forEach((p, j) => {
|
|
const angle = j < this.current ? -Math.PI : 0;
|
|
p.angle = angle;
|
|
p.group.rotation.y = angle;
|
|
});
|
|
this._restackState(this.current, instant);
|
|
this._updateCurvature();
|
|
}
|
|
|
|
// 堆叠状态(z 目标/亮度/投影/几何 LOD),不碰角度;可传未来 current 实现与翻页并行
|
|
_restackState(cur, instant = false) {
|
|
this.pages.forEach((p, j) => {
|
|
const onTop = (j === cur || j === cur - 1);
|
|
const depth = j < cur ? (cur - 1 - j) : (j - cur);
|
|
p.targetZ = onTop ? TOP_LIFT : -depth * STACK_GAP;
|
|
if (instant) {
|
|
p.z = p.targetZ;
|
|
p.group.position.z = p.z;
|
|
}
|
|
p.group.position.y = 0;
|
|
p.group.position.x = 0;
|
|
const dim = 1 - Math.min(depth * 0.06, 0.22);
|
|
p.material.color.setScalar(dim);
|
|
p.mesh.castShadow = onTop;
|
|
if (onTop) {
|
|
this._ensureHiGeo(p);
|
|
if (p.mesh.geometry !== p.hiGeo) p.mesh.geometry = p.hiGeo;
|
|
} else if (p.mesh.geometry !== p.loGeo) {
|
|
p.mesh.geometry = p.loGeo;
|
|
}
|
|
});
|
|
this._ensureTextures();
|
|
}
|
|
|
|
// 需要真实纹理的窗口:左右可见页 + 翻页时会露出的下一层页(按需,首屏最小)
|
|
_pageWindow() {
|
|
const len = this.pages.length;
|
|
const win = new Set();
|
|
// 左页 + 右页(当前)
|
|
for (let d = -1; d <= 0; d++) {
|
|
const i = this.current + d;
|
|
if (i >= 0 && i < len) win.add(i);
|
|
}
|
|
// 向前翻页会露出的下一层右页
|
|
const fwd = this.current + 1;
|
|
if (fwd >= 0 && fwd < len) win.add(fwd);
|
|
// 向后翻页会露出的下一层左页
|
|
const bwd = this.current - 2;
|
|
if (bwd >= 0 && bwd < len) win.add(bwd);
|
|
return win;
|
|
}
|
|
|
|
// 懒加载:窗口内页生成真实纹理,窗口外释放回占位纹理
|
|
_ensureTextures() {
|
|
const win = this._pageWindow();
|
|
const prev = this._textured;
|
|
for (const i of win) {
|
|
if (prev.has(i)) continue;
|
|
const p = this.pages[i];
|
|
const { front, back } = acquirePageTextures(ENTRIES[i]);
|
|
p.material.setPageMaps(front, back);
|
|
p.isPlaceholder = false;
|
|
prev.add(i);
|
|
}
|
|
for (const i of [...prev]) {
|
|
if (win.has(i)) continue;
|
|
const p = this.pages[i];
|
|
releasePageTextures(ENTRIES[i]);
|
|
p.material.setPageMaps(this.placeholder.front, this.placeholder.back);
|
|
p.isPlaceholder = true;
|
|
prev.delete(i);
|
|
}
|
|
}
|
|
|
|
// 预生成即将进入纹理窗口的页:推迟到下一帧,避免占用点击这一帧造成卡顿
|
|
_prefetchForFlip(index) {
|
|
if (index < 0 || index >= ENTRIES.length) return;
|
|
requestAnimationFrame(() => prefetchPageTextures(ENTRIES[index]));
|
|
}
|
|
|
|
_updateCurvature(index = null) {
|
|
// 翻页时整页柔软弧弯:自由边最高、中段有“腹”、书脊侧贴环;顶边略强于底边;静止时完全平整
|
|
const pages = index === null ? this.pages : [this.pages[index]];
|
|
pages.forEach((p) => {
|
|
if (!p.hiGeo) return; // 栈内页未创建高分段几何,跳过
|
|
// 弯曲只作用于高分段几何(顶层/翻页页);栈内极简几何不弯曲
|
|
const pos = p.hiGeo.attributes.position;
|
|
const arr = pos.array;
|
|
const theta = Math.abs(p.angle); // 0..π
|
|
const c = Math.sin(theta); // 0 静止,1 翻页中途
|
|
const A = 0.58 * c; // 抬升幅度(整页弧)
|
|
for (let i = 0; i < arr.length; i += 3) {
|
|
const x = p.hiBase[i];
|
|
const y = p.hiBase[i + 1];
|
|
const s = Math.max(0, x - PAGE_GAP); // 距页内缘 0..W(防浮点误差产生负值→NaN)
|
|
const u = Math.min(1, s / PAGE_W); // 0 内缘 .. 1 自由边
|
|
const yNorm = (y + PAGE_H / 2) / PAGE_H; // 0 底边 .. 1 顶边
|
|
const twist = 0.7 + 0.3 * yNorm; // 顶边抬升略强
|
|
// 主抬升 + 中段“腹”的次级谐波,曲线更饱满、更柔软
|
|
const belly = 0.85 * Math.sin((Math.PI * u) / 2) + 0.15 * Math.sin(Math.PI * u);
|
|
const z = A * twist * belly;
|
|
arr[i + 2] = p.hiBase[i + 2] + z;
|
|
}
|
|
pos.needsUpdate = true;
|
|
});
|
|
}
|
|
|
|
syncTo(index) {
|
|
this.current = clamp(index, 0, this.pages.length - 1);
|
|
this.flip = null;
|
|
this.resetTween = null; // 外部跳转时终止回卷/快进动画,避免旧状态残留
|
|
this._restack(true);
|
|
if (this.onChange) this.onChange(this.current);
|
|
}
|
|
|
|
canFlipForward() { return !this.flip && !this.resetTween; }
|
|
canFlipBackward() { return !this.flip && !this.resetTween; }
|
|
|
|
// 页面 z 是否仍在向目标位置过渡(用于按需渲染:过渡期间需要持续重绘)
|
|
isSettling() {
|
|
return this.pages.some((p) => Math.abs(p.z - p.targetZ) > 0.0001);
|
|
}
|
|
|
|
_finalizeFlip() {
|
|
// 立即把正在翻的页收到目标位置,好让下一页马上开始翻
|
|
if (!this.flip) return;
|
|
const f = this.flip;
|
|
const p = this.pages[f.index];
|
|
p.angle = f.to;
|
|
p.group.rotation.y = f.to;
|
|
p.z = TOP_LIFT;
|
|
p.group.position.z = TOP_LIFT;
|
|
this.current = f.nextCurrent;
|
|
this.flip = null;
|
|
this._restack(false);
|
|
}
|
|
|
|
_startWrap(targetCur) {
|
|
// 回卷/快进:所有页从各自当前角度平滑转到目标角度(左页→右侧 或 右页→左侧),current 跳到目标页
|
|
this.current = targetCur;
|
|
this.flip = null;
|
|
this.resetTween = { t: 0, duration: 0.5 };
|
|
this.pages.forEach((p, j) => {
|
|
p._resetFrom = p.angle;
|
|
p._resetTo = j < targetCur ? -Math.PI : 0;
|
|
});
|
|
this._restackState(targetCur, false);
|
|
if (this.onChange) this.onChange(targetCur);
|
|
}
|
|
|
|
flipForward() {
|
|
if (this.flip) this._finalizeFlip(); // 前一页没翻完也能继续翻
|
|
if (!this.canFlipForward()) return;
|
|
// END 页(所有页都已翻到左侧,current = pages.length):向前 → 回卷到第一页
|
|
if (this.current >= this.pages.length) {
|
|
this._startWrap(0);
|
|
return;
|
|
}
|
|
// 最后一页翻完即进入 END 页:右侧无纸,左页为最后一页的背面
|
|
const nextCurrent = this.current + 1;
|
|
this.flip = { index: this.current, from: 0, to: -Math.PI, t: 0, duration: 0.38, nextCurrent };
|
|
// 堆叠重排与旋转并行:翻页开始即设好目标位置,z-lerp 随旋转同步过渡
|
|
this._restackState(nextCurrent, false);
|
|
this._prefetchForFlip((nextCurrent + 2) % this.pages.length);
|
|
// 优先更新底部导航与信息卡(翻页开始即切换目标页)
|
|
if (this.onChange) this.onChange(nextCurrent);
|
|
}
|
|
|
|
flipBackward() {
|
|
if (this.flip) this._finalizeFlip();
|
|
if (!this.canFlipBackward()) return;
|
|
// 第一页向后:快进到 END 页(所有未翻的右页都翻到左侧,展示末页背面)
|
|
if (this.current === 0) {
|
|
this._startWrap(this.pages.length);
|
|
return;
|
|
}
|
|
const nextCurrent = this.current - 1;
|
|
this.flip = { index: this.current - 1, from: -Math.PI, to: 0, t: 0, duration: 0.38, nextCurrent };
|
|
this._restackState(nextCurrent, false);
|
|
this._prefetchForFlip((nextCurrent - 2 + this.pages.length) % this.pages.length);
|
|
if (this.onChange) this.onChange(nextCurrent);
|
|
}
|
|
|
|
update(dt) {
|
|
// 回卷重置动画:所有页角度从各自起点平滑回到 0(右侧)
|
|
if (this.resetTween) {
|
|
const r = this.resetTween;
|
|
r.t += dt / r.duration;
|
|
const k = clamp(r.t, 0, 1);
|
|
const e = easeInOutCubic(k);
|
|
this.pages.forEach((p) => {
|
|
p.angle = p._resetFrom + (p._resetTo - p._resetFrom) * e;
|
|
p.group.rotation.y = p.angle;
|
|
});
|
|
if (k >= 1) {
|
|
this.resetTween = null;
|
|
this._restack(true);
|
|
}
|
|
}
|
|
|
|
if (this.flip) {
|
|
const f = this.flip;
|
|
f.t += dt / f.duration;
|
|
const k = clamp(f.t, 0, 1);
|
|
const e = easeOutCubic(k);
|
|
const angle = f.from + (f.to - f.from) * e;
|
|
|
|
const p = this.pages[f.index];
|
|
p.angle = angle;
|
|
p.group.rotation.y = angle;
|
|
// 翻页时 z 随进度同步起伏:起止停在顶层高度,中途抬得更高,不归零不瞬跳
|
|
p.z = TOP_LIFT + (FLIP_LIFT - TOP_LIFT) * Math.sin(Math.PI * k);
|
|
p.group.position.z = p.z;
|
|
this._updateCurvature(f.index);
|
|
|
|
if (k >= 1) {
|
|
this.current = f.nextCurrent;
|
|
this.flip = null;
|
|
this._updateCurvature();
|
|
this._ensureTextures();
|
|
}
|
|
}
|
|
|
|
// 页面堆叠进退动画:z 平滑过渡到目标(翻页中的那一页由上面进度直接驱动)
|
|
const lerpK = Math.min(1, dt * 14);
|
|
this.pages.forEach((p, i) => {
|
|
if (this.flip && i === this.flip.index) return;
|
|
p.z += (p.targetZ - p.z) * lerpK;
|
|
p.group.position.z = p.z;
|
|
});
|
|
}
|
|
|
|
dispose() {
|
|
if (this.loGeo) this.loGeo.dispose();
|
|
this.pages.forEach((p) => {
|
|
if (p.hiGeo) p.hiGeo.dispose();
|
|
p.material.dispose();
|
|
});
|
|
}
|
|
}
|