Lazy-load page textures with LRU cache and double-sided materials

Replace eager texture generation with a reference-counted pool
that loads only the visible window and evicts unused pages, and
swap front/back mesh pairs for single meshes using a two-sided
material to halve draw calls. The timeline now drives its texture
window from the camera's visible world range each frame, and the
binder tracks a current-page window. Flip animations also finalize
immediately so rapid flips chain smoothly.
This commit is contained in:
2026-09-19 02:44:15 +08:00
parent 2a173833d0
commit eb165d03c5
4 changed files with 303 additions and 61 deletions
+77 -31
View File
@@ -1,6 +1,6 @@
import * as THREE from 'three';
import { ENTRIES } from '../data/entries.js';
import { PAGE_W, PAGE_H, HOLE_U, getPageTextures } from '../textures/pages.js';
import { PAGE_W, PAGE_H, HOLE_U, acquirePageTextures, releasePageTextures, getPlaceholderTextures, makeTwoSidedMaterial } from '../textures/pages.js';
import { clamp, easeOutCubic } from '../utils/easing.js';
const RING_R = 0.085;
@@ -280,6 +280,8 @@ export class Binder {
this.suedeTex = makeSuedeTexture();
this.suedeNormalTex = makeSuedeNormalMap();
this.placeholder = getPlaceholderTextures();
this._textured = new Set();
this._buildCovers();
this._buildHingePlate();
this._buildRings();
@@ -385,26 +387,17 @@ export class Binder {
}
pa.needsUpdate = true;
const base = Float32Array.from(geo.attributes.position.array);
const { front, back } = getPageTextures(entry);
const frontMat = new THREE.MeshStandardMaterial({
map: front, roughness: 0.88, metalness: 0.0, side: THREE.FrontSide,
transparent: true, alphaTest: 0.5,
// 单 mesh + 双面材质:正面登记卡、背面素描(gl_FrontFacing 自动选择)
// 活页孔用 alphaTest 直接丢弃透明像素(不启用 transparent,避免透明+双面拆两次 draw call)
const mat = makeTwoSidedMaterial(this.placeholder.front, this.placeholder.back, {
alphaTest: 0.5,
});
const backMat = new THREE.MeshStandardMaterial({
map: back, roughness: 0.88, metalness: 0.0, side: THREE.BackSide,
transparent: true, alphaTest: 0.5,
});
const frontMesh = new THREE.Mesh(geo, frontMat);
frontMesh.position.z = 0.0008;
frontMesh.castShadow = true;
const backMesh = new THREE.Mesh(geo, backMat);
backMesh.position.z = -0.0008;
backMesh.castShadow = false;
const mesh = new THREE.Mesh(geo, mat);
mesh.castShadow = false; // 由 _restack 依据是否为顶层页控制
const g = new THREE.Group();
g.add(frontMesh, backMesh);
g.add(mesh);
g.userData.index = i;
g.userData.entry = entry;
@@ -412,8 +405,9 @@ export class Binder {
group: g,
geometry: geo,
base,
frontMat,
backMat,
material: mat,
mesh,
isPlaceholder: true,
angle: 0,
y: 0,
z: 0,
@@ -442,10 +436,45 @@ export class Binder {
p.group.position.x = 0;
// 下层页略暗
const dim = 1 - Math.min(depth * 0.06, 0.22);
p.frontMat.color.setScalar(dim);
p.backMat.color.setScalar(dim);
p.material.color.setScalar(dim);
// 只让顶层(当前右页 + 左侧页)投影,栈内被遮挡页不投影,省阴影 pass
p.mesh.castShadow = (j === this.current || j === this.current - 1);
});
this._updateCurvature();
this._ensureTextures();
}
// 需要真实纹理的窗口:当前页 ±1(左右可见页 + 翻页缓冲)
_pageWindow() {
const len = this.pages.length;
const win = new Set();
for (let d = -1; d <= 1; d++) {
const i = this.current + d;
if (i >= 0 && i < len) win.add(i);
}
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);
}
}
_updateCurvature(index = null) {
@@ -483,20 +512,36 @@ export class Binder {
canFlipForward() { return !this.flip; }
canFlipBackward() { return !this.flip && this.current > 0; }
_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 = 0;
p.group.position.z = 0;
if (f.to === -Math.PI) {
this.current = (this.current + 1) % this.pages.length;
} else {
this.current -= 1;
}
this.flip = null;
this._restack(false);
}
flipForward() {
if (this.flip) this._finalizeFlip(); // 前一页没翻完也能继续翻
if (!this.canFlipForward()) return;
this.flip = { index: this.current, from: 0, to: -Math.PI, t: 0, duration: 0.6 };
const p = this.pages[this.current];
p.z = 0.006; p.targetZ = 0.006; p.group.position.z = 0.006;
// 优先更新底部导航与信息卡(翻页开始即切换目标页)
if (this.onChange) this.onChange((this.current + 1) % this.pages.length);
}
flipBackward() {
if (this.flip) this._finalizeFlip();
if (!this.canFlipBackward()) return;
this.flip = { index: this.current - 1, from: -Math.PI, to: 0, t: 0, duration: 0.6 };
const p = this.pages[this.current - 1];
p.z = 0.006; p.targetZ = 0.006; p.group.position.z = 0.006;
// 优先更新底部导航与信息卡
if (this.onChange) this.onChange(this.current - 1);
}
@@ -512,6 +557,9 @@ export class Binder {
const p = this.pages[f.index];
p.angle = angle;
p.group.rotation.y = angle;
// 翻页时 z 随进度同步起伏(中途 0.006,起止为 0),不瞬跳
p.z = 0.006 * Math.sin(Math.PI * k);
p.group.position.z = p.z;
this._updateCurvature(f.index);
if (k >= 1) {
@@ -524,9 +572,10 @@ export class Binder {
}
}
// 页面堆叠进退动画:z 平滑过渡到目标
// 页面堆叠进退动画:z 平滑过渡到目标(翻页中的那一页由上面进度直接驱动)
const lerpK = Math.min(1, dt * 14);
this.pages.forEach((p) => {
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;
});
@@ -535,10 +584,7 @@ export class Binder {
dispose() {
this.pages.forEach((p) => {
p.geometry.dispose();
p.frontMat.map.dispose();
p.frontMat.dispose();
p.backMat.map.dispose();
p.backMat.dispose();
p.material.dispose();
});
}
}