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
+16 -1
View File
@@ -165,7 +165,7 @@ export class App {
this.interactives.binder.push(m); this.interactives.binder.push(m);
}); });
}); });
this.timeline.cards.forEach((c) => this.interactives.timeline.push(c.front, c.back)); this.timeline.cards.forEach((c) => this.interactives.timeline.push(c.mesh));
// 地理视图由 Cesium 自行拾取,不参与 Three.js 射线检测 // 地理视图由 Cesium 自行拾取,不参与 Three.js 射线检测
} }
@@ -258,6 +258,18 @@ export class App {
return (2 * Math.tan(halfFov) * dist * aspect) / canvas.clientWidth; return (2 * Math.tan(halfFov) * dist * aspect) / canvas.clientWidth;
} }
// 时间轴:按当前相机可视区域刷新纹理窗口(拖拽/缩放/居中动画时连续跟随)
_updateTimelineTextures() {
if (this.view !== 'timeline') return;
const dist = this.camera.position.distanceTo(this.controls.target);
const halfFov = THREE.MathUtils.degToRad(this.camera.fov / 2);
const el = this.renderer.domElement;
const aspect = el.clientWidth / el.clientHeight;
const halfW = Math.tan(halfFov) * dist * aspect;
// 时间轴相机始终在 z 轴看向原点(旋转已锁定),可见世界 x ∈ [-halfW, halfW]
this.timeline.setVisibleWorldRange(-halfW, halfW);
}
_hitTest() { _hitTest() {
const list = this.interactives[this.view]; const list = this.interactives[this.view];
if (!list || !list.length) return null; if (!list || !list.length) return null;
@@ -388,6 +400,7 @@ export class App {
const limit = TIMELINE_SPREAD + 0.6; const limit = TIMELINE_SPREAD + 0.6;
this.timelineOffset = clamp(-this.timeline.getCardX(this.selected), -limit, limit); this.timelineOffset = clamp(-this.timeline.getCardX(this.selected), -limit, limit);
this.timeline.group.position.x = this.timelineOffset; this.timeline.group.position.x = this.timelineOffset;
this.timeline.refreshTextures();
} }
if (!isGlobe) { if (!isGlobe) {
@@ -444,6 +457,8 @@ export class App {
this.binder.update(dt); this.binder.update(dt);
this.timeline.update(dt); this.timeline.update(dt);
this.timeline.drainPending();
this._updateTimelineTextures();
if (this.cameraTween) { if (this.cameraTween) {
const t = this.cameraTween; const t = this.cameraTween;
+77 -31
View File
@@ -1,6 +1,6 @@
import * as THREE from 'three'; import * as THREE from 'three';
import { ENTRIES } from '../data/entries.js'; 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'; import { clamp, easeOutCubic } from '../utils/easing.js';
const RING_R = 0.085; const RING_R = 0.085;
@@ -280,6 +280,8 @@ export class Binder {
this.suedeTex = makeSuedeTexture(); this.suedeTex = makeSuedeTexture();
this.suedeNormalTex = makeSuedeNormalMap(); this.suedeNormalTex = makeSuedeNormalMap();
this.placeholder = getPlaceholderTextures();
this._textured = new Set();
this._buildCovers(); this._buildCovers();
this._buildHingePlate(); this._buildHingePlate();
this._buildRings(); this._buildRings();
@@ -385,26 +387,17 @@ export class Binder {
} }
pa.needsUpdate = true; pa.needsUpdate = true;
const base = Float32Array.from(geo.attributes.position.array); const base = Float32Array.from(geo.attributes.position.array);
const { front, back } = getPageTextures(entry);
const frontMat = new THREE.MeshStandardMaterial({ // 单 mesh + 双面材质:正面登记卡、背面素描(gl_FrontFacing 自动选择)
map: front, roughness: 0.88, metalness: 0.0, side: THREE.FrontSide, // 活页孔用 alphaTest 直接丢弃透明像素(不启用 transparent,避免透明+双面拆两次 draw call)
transparent: true, alphaTest: 0.5, const mat = makeTwoSidedMaterial(this.placeholder.front, this.placeholder.back, {
alphaTest: 0.5,
}); });
const backMat = new THREE.MeshStandardMaterial({ const mesh = new THREE.Mesh(geo, mat);
map: back, roughness: 0.88, metalness: 0.0, side: THREE.BackSide, mesh.castShadow = false; // 由 _restack 依据是否为顶层页控制
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 g = new THREE.Group(); const g = new THREE.Group();
g.add(frontMesh, backMesh); g.add(mesh);
g.userData.index = i; g.userData.index = i;
g.userData.entry = entry; g.userData.entry = entry;
@@ -412,8 +405,9 @@ export class Binder {
group: g, group: g,
geometry: geo, geometry: geo,
base, base,
frontMat, material: mat,
backMat, mesh,
isPlaceholder: true,
angle: 0, angle: 0,
y: 0, y: 0,
z: 0, z: 0,
@@ -442,10 +436,45 @@ export class Binder {
p.group.position.x = 0; p.group.position.x = 0;
// 下层页略暗 // 下层页略暗
const dim = 1 - Math.min(depth * 0.06, 0.22); const dim = 1 - Math.min(depth * 0.06, 0.22);
p.frontMat.color.setScalar(dim); p.material.color.setScalar(dim);
p.backMat.color.setScalar(dim); // 只让顶层(当前右页 + 左侧页)投影,栈内被遮挡页不投影,省阴影 pass
p.mesh.castShadow = (j === this.current || j === this.current - 1);
}); });
this._updateCurvature(); 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) { _updateCurvature(index = null) {
@@ -483,20 +512,36 @@ export class Binder {
canFlipForward() { return !this.flip; } canFlipForward() { return !this.flip; }
canFlipBackward() { return !this.flip && this.current > 0; } 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() { flipForward() {
if (this.flip) this._finalizeFlip(); // 前一页没翻完也能继续翻
if (!this.canFlipForward()) return; if (!this.canFlipForward()) return;
this.flip = { index: this.current, from: 0, to: -Math.PI, t: 0, duration: 0.6 }; 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); if (this.onChange) this.onChange((this.current + 1) % this.pages.length);
} }
flipBackward() { flipBackward() {
if (this.flip) this._finalizeFlip();
if (!this.canFlipBackward()) return; if (!this.canFlipBackward()) return;
this.flip = { index: this.current - 1, from: -Math.PI, to: 0, t: 0, duration: 0.6 }; 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); if (this.onChange) this.onChange(this.current - 1);
} }
@@ -512,6 +557,9 @@ export class Binder {
const p = this.pages[f.index]; const p = this.pages[f.index];
p.angle = angle; p.angle = angle;
p.group.rotation.y = 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); this._updateCurvature(f.index);
if (k >= 1) { if (k >= 1) {
@@ -524,9 +572,10 @@ export class Binder {
} }
} }
// 页面堆叠进退动画:z 平滑过渡到目标 // 页面堆叠进退动画:z 平滑过渡到目标(翻页中的那一页由上面进度直接驱动)
const lerpK = Math.min(1, dt * 14); 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.z += (p.targetZ - p.z) * lerpK;
p.group.position.z = p.z; p.group.position.z = p.z;
}); });
@@ -535,10 +584,7 @@ export class Binder {
dispose() { dispose() {
this.pages.forEach((p) => { this.pages.forEach((p) => {
p.geometry.dispose(); p.geometry.dispose();
p.frontMat.map.dispose(); p.material.dispose();
p.frontMat.dispose();
p.backMat.map.dispose();
p.backMat.dispose();
}); });
} }
} }
+87 -15
View File
@@ -1,6 +1,6 @@
import * as THREE from 'three'; import * as THREE from 'three';
import { ENTRIES } from '../data/entries.js'; 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 { makeTextSprite } from '../utils/labels.js';
import { clamp, easeInOutCubic } from '../utils/easing.js'; import { clamp, easeInOutCubic } from '../utils/easing.js';
@@ -22,6 +22,11 @@ export class Timeline {
this.group = new THREE.Group(); this.group = new THREE.Group();
this.cards = []; this.cards = [];
this.selected = -1; this.selected = -1;
this.placeholder = getPlaceholderTextures();
this._textured = new Set();
this._visRange = null;
this._ready = false;
this._pending = [];
this._buildLine(); this._buildLine();
this._buildCards(); this._buildCards();
@@ -54,27 +59,21 @@ export class Timeline {
ENTRIES.forEach((entry, i) => { ENTRIES.forEach((entry, i) => {
const x = cardX(i, n); const x = cardX(i, n);
const { front, back } = getPageTextures(entry);
// 每张卡片独立几何(带分段,便于翻面时弯曲出纸感) // 每张卡片独立几何(带分段,便于翻面时弯曲出纸感)
const cardGeo = new THREE.PlaneGeometry(0.4, 0.565, 24, 6); const cardGeo = new THREE.PlaneGeometry(0.4, 0.565, 24, 6);
const base = Float32Array.from(cardGeo.attributes.position.array); const base = Float32Array.from(cardGeo.attributes.position.array);
// 翻转容器:正面登记卡 + 背面素描 // 翻转容器:单 mesh + 双面材质(翻面时 gl_FrontFacing 自动切换正/背)
const flipGroup = new THREE.Group(); const flipGroup = new THREE.Group();
flipGroup.position.set(x, 0, 0); flipGroup.position.set(x, 0, 0);
const frontMat = new THREE.MeshStandardMaterial({ map: front, roughness: 0.85, metalness: 0.0, side: THREE.FrontSide }); const mat = makeTwoSidedMaterial(this.placeholder.front, this.placeholder.back, { roughness: 0.86 });
const frontMesh = new THREE.Mesh(cardGeo, frontMat); const cardMesh = new THREE.Mesh(cardGeo, mat);
frontMesh.userData = { view: 'timeline', index: i }; cardMesh.userData = { view: 'timeline', index: i };
frontMesh.castShadow = true; cardMesh.castShadow = true;
const backMat = new THREE.MeshStandardMaterial({ map: back, roughness: 0.88, metalness: 0.0, side: THREE.BackSide }); flipGroup.add(cardMesh);
const backMesh = new THREE.Mesh(cardGeo, backMat);
backMesh.position.z = -0.001;
backMesh.userData = { view: 'timeline', index: i };
flipGroup.add(frontMesh, backMesh);
// 活页上方:主题(常显,随选中上浮,超宽居中换行) // 活页上方:主题(常显,随选中上浮,超宽居中换行)
const name = makeTextSprite(entry.name, { fontSize: 17, color: '#e8d9b4', scale: 0.12, maxWidth: 0.9 }); 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); time.position.set(x, TIME_Y, 0);
this.group.add(flipGroup, name, time); 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) { getCardX(index) {
return cardX(index, ENTRIES.length); return cardX(index, ENTRIES.length);
} }
// 点击翻转:每次点击切换正/反面,带旋转动画 // 点击翻转:每次点击切换正/反面;选中另一张时,其它翻到背面的卡片先翻回正面
flip(index) { 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]; const c = this.cards[index];
if (!c) return; if (!c) return;
c.flipped = !c.flipped; c.flipped = !c.flipped;
+123 -14
View File
@@ -7,7 +7,7 @@ export const PAGE_W = 1.6; // 世界单位:页宽
export const PAGE_H = 2.24; // 世界单位:页高 export const PAGE_H = 2.24; // 世界单位:页高
export const TEX_W = 768; export const TEX_W = 768;
export const TEX_H = 1075; export const TEX_H = 1075;
// 输出纹理与绘制分辨率一致,保证文字清晰 // 输出纹理与绘制分辨率一致,保证文字清晰(显存靠懒加载 + LRU 控制,不牺牲画质)
export const TEX_OUT_W = 768; export const TEX_OUT_W = 768;
export const TEX_OUT_H = 1075; export const TEX_OUT_H = 1075;
export const HOLE_U = [0.1, 0.26, 0.42, 0.58, 0.74, 0.9]; // 活页孔(自顶部归一化,6孔等距) export const HOLE_U = [0.1, 0.26, 0.42, 0.58, 0.74, 0.9]; // 活页孔(自顶部归一化,6孔等距)
@@ -323,19 +323,128 @@ function downscale(src) {
return c; return c;
} }
// 生成页面的两个纹理 // —— 页面纹理池:懒生成 + 引用计数 + LRU 释放 ——
const texCache = new Map(); // 启动时只为“当前可见窗口”生成真实纹理,其余页面用共享占位纹理;
export function getPageTextures(entry) { // 浏览中按需生成,离开窗口的纹理自动释放,控制显存占用。
if (texCache.has(entry.id)) return texCache.get(entry.id);
const front = new THREE.CanvasTexture(downscale(renderFront(entry)));
front.colorSpace = THREE.SRGBColorSpace;
front.anisotropy = 8;
const back = new THREE.CanvasTexture(downscale(renderBack(entry))); const MAX_CACHED_PAGES = 40; // 最多缓存 40 页(80 张)真实纹理
back.colorSpace = THREE.SRGBColorSpace; const texCache = new Map(); // id -> { entry, front, back, refs, lastUsed }
back.anisotropy = 8; let placeholder = null;
const pair = { front, back }; function makeTexture(canvas) {
texCache.set(entry.id, pair); const tex = new THREE.CanvasTexture(canvas);
return pair; tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = 8; // 保持倾斜视角下的清晰度
return tex;
}
// 空白活页纸占位纹理(全局共享一份)
function renderPlaceholder() {
const w = TEX_W, h = TEX_H;
const { canvas, ctx } = makePaper(w, h, { seed: 42 });
drawBorder(ctx, w, h);
drawHoles(ctx, w, h);
return canvas;
}
export function getPlaceholderTextures() {
if (!placeholder) {
const front = makeTexture(downscale(renderPlaceholder()));
const back = makeTexture(downscale(renderPlaceholder()));
placeholder = { front, back };
}
return placeholder;
}
function generatePair(entry) {
const front = makeTexture(downscale(renderFront(entry)));
const back = makeTexture(downscale(renderBack(entry)));
return { front, back };
}
function evictIfNeeded() {
if (texCache.size <= MAX_CACHED_PAGES) return;
const candidates = [...texCache.values()]
.filter((r) => r.refs === 0)
.sort((a, b) => a.lastUsed - b.lastUsed);
while (texCache.size > MAX_CACHED_PAGES && candidates.length) {
const r = candidates.shift();
r.front.dispose();
r.back.dispose();
texCache.delete(r.entry.id);
}
}
export function acquirePageTextures(entry) {
let rec = texCache.get(entry.id);
if (!rec) {
rec = { entry, front: null, back: null, refs: 0, lastUsed: 0 };
texCache.set(entry.id, rec);
}
rec.refs++;
rec.lastUsed = performance.now();
if (!rec.front) {
const pair = generatePair(entry);
rec.front = pair.front;
rec.back = pair.back;
evictIfNeeded();
}
return { front: rec.front, back: rec.back };
}
export function releasePageTextures(entry) {
const rec = texCache.get(entry.id);
if (!rec) return;
rec.refs = Math.max(0, rec.refs - 1);
if (rec.refs === 0) rec.lastUsed = performance.now();
}
// 双面材质:同一 mesh 用 gl_FrontFacing 在正/反面显示不同纹理,
// 替代原先 front/back 两个 mesh,把每页 draw call 减半。
export function makeTwoSidedMaterial(front, back, {
roughness = 0.88,
metalness = 0.0,
transparent = false,
alphaTest = 0,
} = {}) {
const mat = new THREE.MeshStandardMaterial({
map: front, // 仅用于激活 USE_MAP / UV;实际采样由注入代码接管
roughness,
metalness,
side: THREE.DoubleSide,
transparent,
alphaTest,
});
mat.userData.pageMaps = { front, back };
let compiled = null;
mat.onBeforeCompile = (shader) => {
compiled = shader;
shader.uniforms.frontMap = { value: mat.userData.pageMaps.front };
shader.uniforms.backMap = { value: mat.userData.pageMaps.back };
shader.fragmentShader = shader.fragmentShader
.replace(
'#include <common>',
'#include <common>\nuniform sampler2D frontMap;\nuniform sampler2D backMap;',
)
.replace(
'#include <map_fragment>',
`#ifdef USE_MAP
vec4 _sideColor = gl_FrontFacing ? texture2D( frontMap, vMapUv ) : texture2D( backMap, vMapUv );
diffuseColor *= _sideColor;
#endif`,
);
};
mat.setPageMaps = (f, b) => {
mat.userData.pageMaps = { front: f, back: b };
mat.map = f;
if (compiled) {
compiled.uniforms.frontMap.value = f;
compiled.uniforms.backMap.value = b;
}
};
return mat;
} }