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:
+77
-31
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+87
-15
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user