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
+87 -15
View File
@@ -1,6 +1,6 @@
import * as THREE from 'three';
import { ENTRIES } from '../data/entries.js';
import { getPageTextures } from '../textures/pages.js';
import { acquirePageTextures, releasePageTextures, getPlaceholderTextures, makeTwoSidedMaterial } from '../textures/pages.js';
import { makeTextSprite } from '../utils/labels.js';
import { clamp, easeInOutCubic } from '../utils/easing.js';
@@ -22,6 +22,11 @@ export class Timeline {
this.group = new THREE.Group();
this.cards = [];
this.selected = -1;
this.placeholder = getPlaceholderTextures();
this._textured = new Set();
this._visRange = null;
this._ready = false;
this._pending = [];
this._buildLine();
this._buildCards();
@@ -54,27 +59,21 @@ export class Timeline {
ENTRIES.forEach((entry, i) => {
const x = cardX(i, n);
const { front, back } = getPageTextures(entry);
// 每张卡片独立几何(带分段,便于翻面时弯曲出纸感)
const cardGeo = new THREE.PlaneGeometry(0.4, 0.565, 24, 6);
const base = Float32Array.from(cardGeo.attributes.position.array);
// 翻转容器:正面登记卡 + 背面素描
// 翻转容器:单 mesh + 双面材质(翻面时 gl_FrontFacing 自动切换正/背)
const flipGroup = new THREE.Group();
flipGroup.position.set(x, 0, 0);
const frontMat = new THREE.MeshStandardMaterial({ map: front, roughness: 0.85, metalness: 0.0, side: THREE.FrontSide });
const frontMesh = new THREE.Mesh(cardGeo, frontMat);
frontMesh.userData = { view: 'timeline', index: i };
frontMesh.castShadow = true;
const mat = makeTwoSidedMaterial(this.placeholder.front, this.placeholder.back, { roughness: 0.86 });
const cardMesh = new THREE.Mesh(cardGeo, mat);
cardMesh.userData = { view: 'timeline', index: i };
cardMesh.castShadow = true;
const backMat = new THREE.MeshStandardMaterial({ map: back, roughness: 0.88, metalness: 0.0, side: THREE.BackSide });
const backMesh = new THREE.Mesh(cardGeo, backMat);
backMesh.position.z = -0.001;
backMesh.userData = { view: 'timeline', index: i };
flipGroup.add(frontMesh, backMesh);
flipGroup.add(cardMesh);
// 活页上方:主题(常显,随选中上浮,超宽居中换行)
const name = makeTextSprite(entry.name, { fontSize: 17, color: '#e8d9b4', scale: 0.12, maxWidth: 0.9 });
@@ -85,7 +84,7 @@ export class Timeline {
time.position.set(x, TIME_Y, 0);
this.group.add(flipGroup, name, time);
this.cards.push({ card: flipGroup, front: frontMesh, back: backMesh, geo: cardGeo, base, name, nameBaseY: NAME_Y, index: i, flipped: false, flipAnim: null });
this.cards.push({ card: flipGroup, mesh: cardMesh, material: mat, geo: cardGeo, base, name, nameBaseY: NAME_Y, index: i, flipped: false, flipAnim: null, isPlaceholder: true });
});
}
@@ -98,13 +97,86 @@ export class Timeline {
});
}
// 首次进入时间轴视图时由 App 调用,此后由 App 每帧按可视区域驱动窗口
refreshTextures() {
this._ready = true;
}
// App 每帧根据可视区域调用:minI/maxI 为可见卡片索引范围(可为小数)
setVisibleRange(minI, maxI) {
const len = this.cards.length;
const PAD = 4; // 缓冲:可视范围外多生成几页,拖拽时提前就绪
const lo = Math.max(0, Math.floor(minI) - PAD);
const hi = Math.min(len - 1, Math.ceil(maxI) + PAD);
if (this._visRange && this._visRange.min === lo && this._visRange.max === hi) return;
this._visRange = { min: lo, max: hi };
this._refreshTextures();
}
// 世界坐标可视范围 → 索引范围(内部减去 group 偏移;卡片组内坐标 = cardX(i))
setVisibleWorldRange(minX, maxX) {
const n = this.cards.length;
const offset = this.group.position.x;
const lo = (minX - offset) / CARD_GAP + (n - 1) / 2;
const hi = (maxX - offset) / CARD_GAP + (n - 1) / 2;
this.setVisibleRange(lo, hi);
}
_window() {
const win = new Set();
if (!this._visRange) return win;
for (let i = this._visRange.min; i <= this._visRange.max; i++) win.add(i);
return win;
}
_refreshTextures() {
if (!this._ready) return;
const win = this._window();
// 离开窗口:释放真实纹理,换回占位
for (const i of [...this._textured]) {
if (win.has(i)) continue;
const c = this.cards[i];
releasePageTextures(ENTRIES[i]);
c.material.setPageMaps(this.placeholder.front, this.placeholder.back);
c.isPlaceholder = true;
this._textured.delete(i);
}
// 进入窗口:分帧异步生成,避免一次性生成 25 页阻塞主线程
this._pending = [...win].filter((i) => !this._textured.has(i) && this.cards[i].isPlaceholder);
this.drainPending();
}
// 每帧由 App._animate 驱动,逐批生成窗口内纹理(避免阻塞)
drainPending() {
if (!this._pending || !this._pending.length) return;
const budget = 2; // 每帧最多生成 2 页
for (let k = 0; k < budget && this._pending.length; k++) {
const i = this._pending.shift();
const c = this.cards[i];
if (!c || this._textured.has(i)) continue;
const { front, back } = acquirePageTextures(ENTRIES[i]);
c.material.setPageMaps(front, back);
c.isPlaceholder = false;
this._textured.add(i);
}
}
// 返回某张卡片在时间轴组内的横向坐标(用于点击后居中)
getCardX(index) {
return cardX(index, ENTRIES.length);
}
// 点击翻转:每次点击切换正/反面,带旋转动画
// 点击翻转:每次点击切换正/反面;选中另一张时,其它翻到背面的卡片先翻回正面
flip(index) {
this.cards.forEach((o) => {
if (o.index !== index && o.flipped) {
o.flipped = false;
o.flipAnim = { from: o.card.rotation.y, to: 0, t: 0, dur: 0.6 };
}
});
const c = this.cards[index];
if (!c) return;
c.flipped = !c.flipped;