性能优化:Cesium 懒加载+后台预载、按需渲染、射线检测节流、城市标签 primitive 化、时间轴纹理跟随可视区域

- Cesium 改为动态加载(vite 插件不再注入 6MB 脚本),首屏零 Cesium 请求
- 首帧渲染后后台预载地理视图,loading 展示 8 秒期间并行,就绪前地理按钮置灰
- Three.js 按需渲染:静止停止重绘,动画/交互/状态变化时渲染
- 射线检测合帧节流,binder 只测当前页±1、时间轴只测可视窗口
- 移除 vignette 的 mix-blend-mode 全屏合成
- 7342 个城市从 entity 改为 PointPrimitiveCollection + LabelCollection
- 时间轴懒加载窗口跟随可视区域(拖拽/缩放实时跟随)
This commit is contained in:
2026-09-19 03:21:27 +08:00
parent 0c7990a9a8
commit f701862e5a
8 changed files with 175 additions and 32 deletions
+1 -1
View File
@@ -39,7 +39,7 @@
<div class="view-switch" aria-label="视图切换">
<button class="view-btn active" data-view="binder">活页本</button>
<button class="view-btn" data-view="timeline">时间轴</button>
<button class="view-btn" data-view="globe">地理</button>
<button class="view-btn" data-view="globe" disabled title="地理视图加载中">地理</button>
</div>
</footer>
+71 -12
View File
@@ -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);
}
}
}
+11 -10
View File
@@ -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);
});
});
+14 -1
View File
@@ -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() {
+3 -3
View File
@@ -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);
}
+31 -2
View File
@@ -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) => {
+6 -1
View File
@@ -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;
+38 -2
View File
@@ -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()],
});