From f701862e5ae949074fa35a3a7ca80fe38186002e Mon Sep 17 00:00:00 2001 From: fukunee Date: Sat, 19 Sep 2026 03:21:27 +0800 Subject: [PATCH] =?UTF-8?q?=E6=80=A7=E8=83=BD=E4=BC=98=E5=8C=96=EF=BC=9ACe?= =?UTF-8?q?sium=20=E6=87=92=E5=8A=A0=E8=BD=BD+=E5=90=8E=E5=8F=B0=E9=A2=84?= =?UTF-8?q?=E8=BD=BD=E3=80=81=E6=8C=89=E9=9C=80=E6=B8=B2=E6=9F=93=E3=80=81?= =?UTF-8?q?=E5=B0=84=E7=BA=BF=E6=A3=80=E6=B5=8B=E8=8A=82=E6=B5=81=E3=80=81?= =?UTF-8?q?=E5=9F=8E=E5=B8=82=E6=A0=87=E7=AD=BE=20primitive=20=E5=8C=96?= =?UTF-8?q?=E3=80=81=E6=97=B6=E9=97=B4=E8=BD=B4=E7=BA=B9=E7=90=86=E8=B7=9F?= =?UTF-8?q?=E9=9A=8F=E5=8F=AF=E8=A7=86=E5=8C=BA=E5=9F=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cesium 改为动态加载(vite 插件不再注入 6MB 脚本),首屏零 Cesium 请求 - 首帧渲染后后台预载地理视图,loading 展示 8 秒期间并行,就绪前地理按钮置灰 - Three.js 按需渲染:静止停止重绘,动画/交互/状态变化时渲染 - 射线检测合帧节流,binder 只测当前页±1、时间轴只测可视窗口 - 移除 vignette 的 mix-blend-mode 全屏合成 - 7342 个城市从 entity 改为 PointPrimitiveCollection + LabelCollection - 时间轴懒加载窗口跟随可视区域(拖拽/缩放实时跟随) --- index.html | 2 +- src/core/App.js | 83 ++++++++++++++++++++++++++++++++++------ src/main.js | 21 +++++----- src/scene/CesiumGlobe.js | 15 +++++++- src/scene/binder.js | 6 +-- src/scene/timeline.js | 33 +++++++++++++++- src/styles.css | 7 +++- vite.config.js | 40 ++++++++++++++++++- 8 files changed, 175 insertions(+), 32 deletions(-) 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()], });