diff --git a/src/core/App.js b/src/core/App.js index 8f2e2d8..9e112d9 100644 --- a/src/core/App.js +++ b/src/core/App.js @@ -165,7 +165,7 @@ export class App { 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 射线检测 } @@ -258,6 +258,18 @@ export class App { 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() { const list = this.interactives[this.view]; if (!list || !list.length) return null; @@ -388,6 +400,7 @@ export class App { const limit = TIMELINE_SPREAD + 0.6; this.timelineOffset = clamp(-this.timeline.getCardX(this.selected), -limit, limit); this.timeline.group.position.x = this.timelineOffset; + this.timeline.refreshTextures(); } if (!isGlobe) { @@ -444,6 +457,8 @@ export class App { this.binder.update(dt); this.timeline.update(dt); + this.timeline.drainPending(); + this._updateTimelineTextures(); if (this.cameraTween) { const t = this.cameraTween; diff --git a/src/scene/binder.js b/src/scene/binder.js index a9494aa..06d6e9b 100644 --- a/src/scene/binder.js +++ b/src/scene/binder.js @@ -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(); }); } } diff --git a/src/scene/timeline.js b/src/scene/timeline.js index 094b0e6..0451da0 100644 --- a/src/scene/timeline.js +++ b/src/scene/timeline.js @@ -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; diff --git a/src/textures/pages.js b/src/textures/pages.js index 5054fe8..a7d4181 100644 --- a/src/textures/pages.js +++ b/src/textures/pages.js @@ -7,7 +7,7 @@ export const PAGE_W = 1.6; // 世界单位:页宽 export const PAGE_H = 2.24; // 世界单位:页高 export const TEX_W = 768; export const TEX_H = 1075; -// 输出纹理与绘制分辨率一致,保证文字清晰 +// 输出纹理与绘制分辨率一致,保证文字清晰(显存靠懒加载 + LRU 控制,不牺牲画质) export const TEX_OUT_W = 768; export const TEX_OUT_H = 1075; 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; } -// 生成页面的两个纹理 -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; +// —— 页面纹理池:懒生成 + 引用计数 + LRU 释放 —— +// 启动时只为“当前可见窗口”生成真实纹理,其余页面用共享占位纹理; +// 浏览中按需生成,离开窗口的纹理自动释放,控制显存占用。 - const back = new THREE.CanvasTexture(downscale(renderBack(entry))); - back.colorSpace = THREE.SRGBColorSpace; - back.anisotropy = 8; +const MAX_CACHED_PAGES = 40; // 最多缓存 40 页(80 张)真实纹理 +const texCache = new Map(); // id -> { entry, front, back, refs, lastUsed } +let placeholder = null; - const pair = { front, back }; - texCache.set(entry.id, pair); - return pair; +function makeTexture(canvas) { + const tex = new THREE.CanvasTexture(canvas); + 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 ', + '#include \nuniform sampler2D frontMap;\nuniform sampler2D backMap;', + ) + .replace( + '#include ', + `#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; }