diff --git a/index.html b/index.html
index 5cb73d5..f51f8d6 100644
--- a/index.html
+++ b/index.html
@@ -39,7 +39,7 @@
-
+
diff --git a/src/core/App.js b/src/core/App.js
index 9e112d9..59cece7 100644
--- a/src/core/App.js
+++ b/src/core/App.js
@@ -21,6 +21,7 @@ export class App {
this.timelineSlide = null;
this.zoomTarget = null;
this.cameraMemory = {};
+ this._needsRender = true; // 按需渲染:首帧必渲染
this._initRenderer();
this._initLights();
@@ -36,6 +37,14 @@ export class App {
this._initEvents();
this._applyRotationLimits(CAMERA_POSES.binder);
+ // 默认新打开时:活页本放到最小(相机沿默认视角拉到最远)
+ {
+ const bp = CAMERA_POSES.binder;
+ const dir = bp.pos.clone().sub(bp.target).normalize();
+ this.camera.position.copy(bp.target).addScaledVector(dir, bp.max);
+ this.controls.target.copy(bp.target);
+ this.controls.update();
+ }
this._select(0, { syncBinder: false });
this.binder.syncTo(0);
@@ -45,6 +54,13 @@ export class App {
requestAnimationFrame(this._animate);
}
+ // 首屏加载完成后由 main.js 调用:后台预载地理视图,就绪后激活“地理”按钮
+ preloadGlobe() {
+ const btn = document.querySelector('.view-btn[data-view="globe"]');
+ this.globe.viewerReady.then(() => { if (btn) btn.disabled = false; });
+ this.globe.ensureCreated();
+ }
+
_initRenderer() {
const canvas = document.getElementById('scene');
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
@@ -74,6 +90,7 @@ export class App {
this.controls.mouseButtons.MIDDLE = -1;
this.controls.mouseButtons.RIGHT = -1;
this.controls.rotateSpeed = 0.4; // 整体降速(无边缘减速)
+ this.controls.addEventListener('change', () => { this._needsRender = true; });
this.controls.update();
}
@@ -84,15 +101,16 @@ export class App {
const key = new THREE.DirectionalLight(0xffe6c0, 2.4);
key.position.set(3.2, 4.4, 3.6);
key.castShadow = true;
- key.shadow.mapSize.set(1024, 1024);
+ key.shadow.mapSize.set(2048, 2048);
key.shadow.camera.near = 0.5;
key.shadow.camera.far = 30;
- key.shadow.camera.left = -5;
- key.shadow.camera.right = 5;
- key.shadow.camera.top = 6;
- key.shadow.camera.bottom = -6;
- key.shadow.bias = -0.0002;
+ key.shadow.camera.left = -3.5;
+ key.shadow.camera.right = 3.5;
+ key.shadow.camera.top = 4;
+ key.shadow.camera.bottom = -4;
+ key.shadow.bias = -0.0005;
this.scene.add(key);
+ this.scene.add(key.target);
this.keyLight = key;
const rim = new THREE.DirectionalLight(0x8fa0d0, 0.7);
@@ -116,7 +134,7 @@ export class App {
this.timeline = new Timeline();
this.globe = new CesiumGlobe({ onPick: (i) => this._pickGlobe(i) });
- this.globe.ensureCreated(); // 打开页面时就渲染缓存好三个视图
+ // Cesium 懒加载:首次进入“地理”视图时才真正创建 viewer
this.scene.add(this.binder.group);
this.scene.add(this.timeline.group);
@@ -173,6 +191,7 @@ export class App {
this.raycaster = new THREE.Raycaster();
this.pointer = new THREE.Vector2();
this._drag = null;
+ this._hoverDirty = false;
const canvas = this.renderer.domElement;
const setPointer = (e) => {
@@ -207,11 +226,11 @@ export class App {
this.timelineOffset = clamp(this._drag.offset + dx * this._worldPerPixel(), -limit, limit);
this.timeline.group.position.x = this.timelineOffset;
canvas.style.cursor = 'grabbing';
+ this._needsRender = true;
}
return;
}
- const hit = this._hitTest();
- canvas.style.cursor = hit ? 'pointer' : 'grab';
+ this._hoverDirty = true; // 射线检测移到 _animate 中每帧至多执行一次
});
canvas.addEventListener('pointerup', (e) => {
@@ -271,8 +290,21 @@ export class App {
}
_hitTest() {
- const list = this.interactives[this.view];
- if (!list || !list.length) return null;
+ let list;
+ if (this.view === 'binder') {
+ // 只检测顶层可点击页(左右页 + 翻页缓冲),避免对全部 182 页求交
+ list = [];
+ const cur = this.binder.current;
+ for (let d = -1; d <= 1; d++) {
+ const i = cur + d;
+ if (i >= 0 && i < this.binder.pages.length) list.push(this.binder.pages[i].mesh);
+ }
+ } else if (this.view === 'timeline') {
+ list = this.timeline.pickableMeshes();
+ } else {
+ return null; // 地理视图由 Cesium 自行拾取
+ }
+ if (!list.length) return null;
this.raycaster.setFromCamera(this.pointer, this.camera);
const hits = this.raycaster.intersectObjects(list, false);
return hits.length ? hits[0] : null;
@@ -331,10 +363,13 @@ export class App {
this.globe.setSelected(this.selected);
if (this.view === 'globe') this.globe.flyToEntry(this.selected); // 地球页切换时自动居中
if (syncBinder) this.binder.syncTo(this.selected);
+ this._needsRender = true;
}
switchView(view) {
if (view === this.view || !CAMERA_POSES[view]) return;
+ // 地理视图尚未就绪(按钮仍置灰)时忽略切换,键盘也无法绕过
+ if (view === 'globe' && !this.globe.viewer) return;
// 离开当前视图前,记住它的相机位置
if (this.view !== 'globe') {
this.cameraMemory[this.view] = {
@@ -416,6 +451,7 @@ export class App {
this.controls.target.copy(pose.target);
}
}
+ this._needsRender = true;
}
_applyRotationLimits(pose) {
@@ -449,16 +485,27 @@ export class App {
this.camera.updateProjectionMatrix();
this.renderer.setSize(window.innerWidth, window.innerHeight);
if (this.globe) this.globe.resize();
+ this._needsRender = true;
}
_animate() {
requestAnimationFrame(this._animate);
const dt = Math.min(this.clock.getDelta(), 0.05);
+ // 是否有进行中的动画(决定本帧是否需要重绘)
+ const animating =
+ this.binder.flip !== null ||
+ this.timeline.hasActiveFlip() ||
+ this.cameraTween !== null ||
+ this.zoomTarget !== null ||
+ this.timelineSlide !== null;
+ if (animating) this._needsRender = true;
+
this.binder.update(dt);
this.timeline.update(dt);
this.timeline.drainPending();
this._updateTimelineTextures();
+ if (this.timeline.consumeDirty()) this._needsRender = true;
if (this.cameraTween) {
const t = this.cameraTween;
@@ -492,7 +539,19 @@ export class App {
if (k >= 1) this.timelineSlide = null;
}
+ // 鼠标悬停检测:每帧至多一次(pointermove 高频事件被合帧)
+ if (this._hoverDirty) {
+ this._hoverDirty = false;
+ const hit = this._hitTest();
+ this.renderer.domElement.style.cursor = hit ? 'pointer' : 'grab';
+ }
+
this.controls.update();
- this.renderer.render(this.scene, this.camera);
+
+ // 按需渲染:仅在状态/动画/交互变化时重绘,静止时停止渲染
+ if (this._needsRender) {
+ this._needsRender = false;
+ this.renderer.render(this.scene, this.camera);
+ }
}
}
diff --git a/src/main.js b/src/main.js
index 2362535..042765e 100644
--- a/src/main.js
+++ b/src/main.js
@@ -9,15 +9,16 @@ for (const ev of ['copy', 'cut', 'contextmenu']) {
const app = new App();
window.__app = app; // 便于调试
-// 加载界面:等三个视图初始化完成(最少展示 1 秒,最多等 8 秒)后淡出
+// 加载界面:等首帧渲染后立即后台预载地理视图(与 loading 并行,不等其淡出);
+// loading 最少展示 3 秒(期间 Cesium 预载已同步进行)。
const t0 = Date.now();
-const MIN_DISPLAY = 1000;
-Promise.race([
- app.globe.ready,
- new Promise((r) => setTimeout(r, 8000)),
-]).then(() => {
- const wait = Math.max(0, MIN_DISPLAY - (Date.now() - t0));
- setTimeout(() => {
- document.getElementById('loading').classList.add('hidden');
- }, wait);
+const MIN_DISPLAY = 8000;
+requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ app.preloadGlobe(); // 首帧已渲染,立即预载地理界面
+ const wait = Math.max(0, MIN_DISPLAY - (Date.now() - t0));
+ setTimeout(() => {
+ document.getElementById('loading').classList.add('hidden');
+ }, wait);
+ });
});
diff --git a/src/scene/CesiumGlobe.js b/src/scene/CesiumGlobe.js
index 9cfbe10..b3c08dd 100644
--- a/src/scene/CesiumGlobe.js
+++ b/src/scene/CesiumGlobe.js
@@ -124,13 +124,20 @@ export class CesiumGlobe {
this._visible = false;
this._pendingData = 2; // 国家边界 + 城市数据
this.ready = new Promise((resolve) => { this._resolveReady = resolve; });
+ this.viewerReady = new Promise((resolve) => { this._resolveViewerReady = resolve; });
}
async ensureCreated() {
if (this._created) return;
this._created = true;
- await loadCesium();
+ try {
+ await loadCesium();
+ } catch (err) {
+ this._created = false; // 失败可重试
+ console.warn('[CesiumGlobe] Cesium 脚本加载失败:', err);
+ return;
+ }
// 调整实体渲染顺序:先渲染标签(城市/国家名),再渲染 Billboard(标记便签),
// 让词条标记始终显示在城市/国家名的上层。
@@ -255,6 +262,12 @@ export class CesiumGlobe {
this.setVisible(this._visible);
this.resize();
this.flyToEntry(this.selected);
+
+ // 地球已就绪(影像/地形/城市数据继续后台加载),通知 App 隐藏加载遮罩
+ if (this._resolveViewerReady) {
+ this._resolveViewerReady();
+ this._resolveViewerReady = null;
+ }
}
_buildMarkers() {
diff --git a/src/scene/binder.js b/src/scene/binder.js
index 06d6e9b..a9babb6 100644
--- a/src/scene/binder.js
+++ b/src/scene/binder.js
@@ -5,7 +5,7 @@ import { clamp, easeOutCubic } from '../utils/easing.js';
const RING_R = 0.085;
const RING_TUBE = 0.0115;
-const STACK_GAP = 0.004;
+const STACK_GAP = 0.002;
const PAGE_GAP = 0.02; // 页内缘离书脊的小空隙
function makeLeatherTexture({ emblem = false } = {}) {
@@ -533,7 +533,7 @@ export class Binder {
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 };
+ this.flip = { index: this.current, from: 0, to: -Math.PI, t: 0, duration: 0.38 };
// 优先更新底部导航与信息卡(翻页开始即切换目标页)
if (this.onChange) this.onChange((this.current + 1) % this.pages.length);
}
@@ -541,7 +541,7 @@ export class Binder {
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 };
+ this.flip = { index: this.current - 1, from: -Math.PI, to: 0, t: 0, duration: 0.38 };
// 优先更新底部导航与信息卡
if (this.onChange) this.onChange(this.current - 1);
}
diff --git a/src/scene/timeline.js b/src/scene/timeline.js
index 0451da0..fc6d07a 100644
--- a/src/scene/timeline.js
+++ b/src/scene/timeline.js
@@ -27,6 +27,7 @@ export class Timeline {
this._visRange = null;
this._ready = false;
this._pending = [];
+ this._dirty = false;
this._buildLine();
this._buildCards();
@@ -141,17 +142,21 @@ export class Timeline {
c.material.setPageMaps(this.placeholder.front, this.placeholder.back);
c.isPlaceholder = true;
this._textured.delete(i);
+ this._dirty = true;
}
// 进入窗口:分帧异步生成,避免一次性生成 25 页阻塞主线程
- this._pending = [...win].filter((i) => !this._textured.has(i) && this.cards[i].isPlaceholder);
+ const next = [...win].filter((i) => !this._textured.has(i) && this.cards[i].isPlaceholder);
+ if (next.length) this._dirty = true;
+ this._pending = next;
this.drainPending();
}
// 每帧由 App._animate 驱动,逐批生成窗口内纹理(避免阻塞)
drainPending() {
- if (!this._pending || !this._pending.length) return;
+ if (!this._pending || !this._pending.length) return false;
const budget = 2; // 每帧最多生成 2 页
+ let did = false;
for (let k = 0; k < budget && this._pending.length; k++) {
const i = this._pending.shift();
const c = this.cards[i];
@@ -160,7 +165,21 @@ export class Timeline {
c.material.setPageMaps(front, back);
c.isPlaceholder = false;
this._textured.add(i);
+ this._dirty = true;
+ did = true;
}
+ return did;
+ }
+
+ hasActiveFlip() {
+ return this.cards.some((c) => c.flipAnim);
+ }
+
+ // App 每帧消费“纹理窗口变化”标志,用于按需渲染
+ consumeDirty() {
+ const d = this._dirty;
+ this._dirty = false;
+ return d;
}
// 返回某张卡片在时间轴组内的横向坐标(用于点击后居中)
@@ -168,6 +187,16 @@ export class Timeline {
return cardX(index, ENTRIES.length);
}
+ // 射线检测用:只返回可视窗口内的卡片 mesh(减少求交对象)
+ pickableMeshes() {
+ if (!this._visRange) return this.cards.map((c) => c.mesh);
+ const list = [];
+ for (let i = this._visRange.min; i <= this._visRange.max; i++) {
+ if (this.cards[i]) list.push(this.cards[i].mesh);
+ }
+ return list;
+ }
+
// 点击翻转:每次点击切换正/反面;选中另一张时,其它翻到背面的卡片先翻回正面
flip(index) {
this.cards.forEach((o) => {
diff --git a/src/styles.css b/src/styles.css
index 5e10d29..c4e49ed 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -55,7 +55,7 @@ html, body {
.vignette {
position: absolute; inset: 0; pointer-events: none;
background: radial-gradient(ellipse at 50% 42%, transparent 40%, rgba(8, 5, 3, 0.55) 100%);
- mix-blend-mode: multiply;
+ /* 不启用 mix-blend-mode,避免每帧全屏合成开销(深色半透明覆盖即可) */
}
.grain {
position: absolute; inset: 0; pointer-events: none; opacity: 0.06;
@@ -114,6 +114,11 @@ html, body {
transition: all 0.25s ease;
}
.view-btn:hover { border-color: var(--gold); color: #fff; }
+.view-btn:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+ pointer-events: none;
+}
.view-btn.active {
background: linear-gradient(180deg, rgba(201,162,39,0.85), rgba(150,115,20,0.85));
color: #1c1308; border-color: var(--gold); font-weight: 600;
diff --git a/vite.config.js b/vite.config.js
index e0d4b1c..a634b25 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -1,6 +1,42 @@
import { defineConfig } from 'vite';
-import cesium from 'vite-plugin-cesium';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import serveStatic from 'serve-static';
+
+// Cesium 懒加载:复制静态资源、注入 CSS,但不注入 6MB 的 Cesium.js 脚本——
+// 脚本由 CesiumGlobe 首次进入“地理”视图时动态加载,避免阻塞首屏。
+const CESIUM_URL = '/cesium/';
+
+function cesiumLazy() {
+ let isBuild = false;
+ return {
+ name: 'cesium-lazy',
+ config(_c, { command }) {
+ isBuild = command === 'build';
+ },
+ configureServer({ middlewares }) {
+ // dev:直接静态服务 Cesium 资源(未压缩版便于调试)
+ const dir = path.join(process.cwd(), 'node_modules/cesium/Build/CesiumUnminified');
+ middlewares.use(CESIUM_URL, serveStatic(dir));
+ },
+ async closeBundle() {
+ if (!isBuild) return;
+ const src = path.join(process.cwd(), 'node_modules/cesium/Build/Cesium');
+ const out = path.join(process.cwd(), 'dist', 'cesium');
+ for (const d of ['Assets', 'ThirdParty', 'Workers', 'Widgets']) {
+ await fs.cp(path.join(src, d), path.join(out, d), { recursive: true });
+ }
+ await fs.copyFile(path.join(src, 'Cesium.js'), path.join(out, 'Cesium.js'));
+ },
+ transformIndexHtml() {
+ return [{
+ tag: 'link',
+ attrs: { rel: 'stylesheet', href: `${CESIUM_URL}Widgets/widgets.css` },
+ }];
+ },
+ };
+}
export default defineConfig({
- plugins: [cesium()],
+ plugins: [cesiumLazy()],
});