直接上示例代码(deepseek提供):
修改一下3D模型瓦片地址即可测试缓存功能
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Cesium 3D Tiles 智能预加载缓存系统</title>
<style>
html, body, #cesiumContainer {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
/* 控制面板通用样式 */
.control-panel {
position: absolute;
z-index: 200;
background: rgba(20, 20, 30, 0.85);
backdrop-filter: blur(8px);
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.2);
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
color: #e0e0e0;
font-size: 13px;
transition: all 0.2s ease;
}
/* 状态条 */
.info-panel {
position: absolute;
top: 15px;
left: 15px;
padding: 8px 16px;
font-family: monospace;
font-size: 12px;
background: rgba(0,0,0,0.65);
border-radius: 20px;
pointer-events: none;
z-index: 100;
border-left: 3px solid #00a8ff;
}
/* 预加载控制面板 */
.preload-panel {
bottom: 20px;
right: 20px;
width: 320px;
padding: 12px 16px;
pointer-events: auto;
display: flex;
flex-direction: column;
gap: 10px;
transition: transform 0.2s;
}
.preload-header {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: bold;
border-bottom: 1px solid rgba(255,255,255,0.2);
padding-bottom: 6px;
margin-bottom: 4px;
}
.preload-title {
font-size: 14px;
letter-spacing: 1px;
}
.preload-badge {
background: #00a8ff;
padding: 2px 8px;
border-radius: 20px;
font-size: 10px;
font-weight: bold;
}
.progress-container {
width: 100%;
background: rgba(0,0,0,0.5);
border-radius: 20px;
overflow: hidden;
height: 8px;
margin: 5px 0;
}
.progress-bar {
width: 0%;
height: 100%;
background: linear-gradient(90deg, #00a8ff, #4caf50);
border-radius: 20px;
transition: width 0.2s ease;
}
.stats {
display: flex;
justify-content: space-between;
font-size: 11px;
font-family: monospace;
margin: 4px 0;
}
.current-file {
font-size: 10px;
background: rgba(0,0,0,0.6);
padding: 5px 8px;
border-radius: 6px;
word-break: break-all;
max-height: 40px;
overflow-y: auto;
font-family: monospace;
color: #bbffaa;
}
button {
background: #2c3e66;
border: none;
color: white;
padding: 8px 12px;
border-radius: 30px;
font-weight: bold;
cursor: pointer;
transition: all 0.2s;
font-size: 12px;
margin-top: 4px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
button:hover {
background: #00a8ff;
transform: scale(1.02);
box-shadow: 0 2px 10px rgba(0,168,255,0.3);
}
button:active {
transform: scale(0.98);
}
button:disabled {
background: #555;
cursor: not-allowed;
opacity: 0.6;
transform: none;
}
.clear-btn {
background: #5a2a2a;
margin-top: 5px;
}
.clear-btn:hover {
background: #c0392b;
}
.status-text {
font-size: 11px;
color: #ccc;
text-align: center;
}
hr {
border-color: rgba(255,255,255,0.1);
margin: 4px 0;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/localforage@1.10.0/dist/localforage.min.js"></script>
<script src="https://cesium.com/downloads/cesiumjs/releases/1.125/Build/Cesium/Cesium.js"></script>
<link href="https://cesium.com/downloads/cesiumjs/releases/1.125/Build/Cesium/Widgets/widgets.css" rel="stylesheet">
</head>
<body>
<div id="cesiumContainer"></div>
<div class="info-panel" id="cacheStatus">📦 缓存系统就绪 | 等待数据加载</div>
<!-- 预加载控制面板 -->
<div class="control-panel preload-panel" id="preloadPanel">
<div class="preload-header">
<span class="preload-title">🗂️ 智能预加载</span>
<span class="preload-badge">3D Tiles Cache</span>
</div>
<div class="progress-container">
<div class="progress-bar" id="preloadProgressBar"></div>
</div>
<div class="stats">
<span>📊 进度: <span id="preloadPercent">0</span>%</span>
<span>✅ <span id="completedCount">0</span> / <span id="totalCount">0</span></span>
<span>⚡ <span id="cachedSkipCount">0</span> 已缓存</span>
</div>
<div class="current-file" id="currentFileInfo">⚡ 等待预加载指令</div>
<div style="display: flex; gap: 8px;">
<button id="startPreloadBtn">🚀 一键预加载全部瓦片</button>
<button id="clearCacheBtn" class="clear-btn">🗑️ 清理缓存</button>
</div>
<div class="status-text" id="preloadStatusMsg">✨ 点击按钮开始预加载,弱网环境友好</div>
</div>
<script>
(async function() {
// ----------------------------- 1. 初始化 IndexedDB 缓存实例 -----------------------------
const cacheDb = localforage.createInstance({
name: "CesiumTerrainCache",
storeName: "tile_binaries",
driver: localforage.INDEXEDDB,
description: "用于缓存 Cesium 3D Tiles 瓦片数据"
});
// 辅助函数:清理 URL 参数生成缓存键
function getCacheKey(url) {
const cleanUrl = url.split('?')[0];
return cleanUrl;
}
// 判断哪些资源需要缓存 (与瓦片加载拦截一致)
function shouldCache(url) {
return url.includes('/terra_b3dms/') &&
(url.endsWith('.b3dm') || url.endsWith('.i3dm') || url.includes('tileset.json') || url.endsWith('.json'));
}
// 保存原生 XHR 实现
const originalLoadWithXhr = Cesium.Resource._Implementations.loadWithXhr;
// 替换实现,加入缓存逻辑 (保留原有)
Cesium.Resource._Implementations.loadWithXhr = function(url, responseType, method, data, headers, deferred, overrideMimeType) {
if (!shouldCache(url)) {
return originalLoadWithXhr.call(this, url, responseType, method, data, headers, deferred, overrideMimeType);
}
const cacheKey = getCacheKey(url);
cacheDb.getItem(cacheKey).then(cachedData => {
if (cachedData) {
// 命中缓存,直接返回
if (responseType === 'arraybuffer') {
deferred.resolve(cachedData);
} else if (responseType === 'blob') {
deferred.resolve(new Blob([cachedData]));
} else {
deferred.resolve(cachedData);
}
return;
}
// 未命中则发起网络请求
originalLoadWithXhr.call(this, url, responseType, method, data, headers, {
resolve: (responseData) => {
if (responseData) {
cacheDb.setItem(cacheKey, responseData).catch(e => console.warn('Cache save err:', e));
}
deferred.resolve(responseData);
},
reject: (error) => {
deferred.reject(error);
}
}, overrideMimeType);
}).catch(err => {
console.error('IndexedDB error fallback', err);
originalLoadWithXhr.call(this, url, responseType, method, data, headers, deferred, overrideMimeType);
});
};
// ----------------------------- 2. 初始化 Cesium Viewer 并加载主 tileset -----------------------------
const viewer = new Cesium.Viewer("cesiumContainer", {
baseLayerPicker: false,
infoBox: false,
timeline: false,
navigationInstructionsInitiallyVisible: false,
sceneModePicker: true,
terrainProvider: new Cesium.EllipsoidTerrainProvider()
});
// 主 tileset URL (可修改为你实际路径)
const tilesetUrl = "你的瓦片访问路径/terra_b3dms/tileset.json";
let mainTileset = null;
try {
mainTileset = await Cesium.Cesium3DTileset.fromUrl(tilesetUrl, {
useDBCache: false, // 避免与官方缓存冲突
maximumScreenSpaceError: 1,
preferLeaves: true,
maximumNumberOfLoadedTiles: 1000
});
viewer.scene.primitives.add(mainTileset);
await viewer.zoomTo(mainTileset);
document.getElementById('cacheStatus').innerHTML = `✅ 3D Tiles 已加载 | 缓存拦截生效`;
} catch (error) {
console.error('加载失败', error);
document.getElementById('cacheStatus').innerHTML = `❌ 加载失败: ${error.message} | 请检查路径`;
}
// ----------------------------- 3. 预加载核心模块 -----------------------------
// 工具: 相对路径转绝对路径 (基于 tileset 所在目录)
function resolveUri(baseUrl, relativeUri) {
if (!relativeUri) return null;
if (relativeUri.startsWith('http://') || relativeUri.startsWith('https://') || relativeUri.startsWith('//')) {
return relativeUri;
}
// 处理绝对路径风格
if (relativeUri.startsWith('/')) {
const urlObj = new URL(baseUrl);
return urlObj.origin + relativeUri;
}
// 相对路径: 提取 baseUrl 目录
const lastSlash = baseUrl.lastIndexOf('/');
let baseDir = baseUrl.substring(0, lastSlash + 1);
if (!baseDir.endsWith('/')) baseDir += '/';
return new URL(relativeUri, baseDir).href;
}
// 递归收集所有瓦片资源URL (包括子 tileset.json 和 实际瓦片文件)
async function collectAllTileUrls(rootTilesetUrl) {
const allUrlsSet = new Set(); // 存储所有需要缓存的资源URL
const visitedJsonSet = new Set(); // 避免循环引用 tileset.json
// 递归解析 tileset.json 节点
async function processTileset(jsonUrl) {
const normalizedUrl = getCacheKey(jsonUrl);
if (visitedJsonSet.has(normalizedUrl)) return;
visitedJsonSet.add(normalizedUrl);
// 添加 tileset.json 本身到缓存列表
allUrlsSet.add(normalizedUrl);
let tilesetJson;
try {
// 获取 tileset.json 内容 (使用 fetch 但走缓存拦截,确保未来可用)
const response = await fetch(normalizedUrl);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
tilesetJson = await response.json();
} catch (err) {
console.warn(`获取 tileset.json 失败: ${normalizedUrl}`, err);
return;
}
// 获取根节点 (root)
const root = tilesetJson.root;
if (!root) return;
// 递归遍历节点树收集 content.uri
async function traverseNode(node, basePath) {
if (!node) return;
// 处理 content
if (node.content && node.content.uri) {
let contentUri = node.content.uri;
let absoluteUrl = resolveUri(normalizedUrl, contentUri);
if (absoluteUrl) {
// 判断是否为子瓦片集 (json 后缀或者包含 tileset)
if (absoluteUrl.endsWith('.json') || absoluteUrl.includes('tileset')) {
// 递归加载子 tileset
await processTileset(absoluteUrl);
} else {
// 普通瓦片 (b3dm/i3dm/pnts等)
allUrlsSet.add(getCacheKey(absoluteUrl));
}
}
}
// 处理 children
if (node.children && Array.isArray(node.children)) {
for (const child of node.children) {
await traverseNode(child, basePath);
}
}
}
await traverseNode(root, normalizedUrl);
}
await processTileset(rootTilesetUrl);
// 返回所有唯一资源URL
return Array.from(allUrlsSet);
}
// 并发队列控制器 (限制同时请求数,避免浏览器崩溃)
class ConcurrencyQueue {
constructor(limit = 6) {
this.limit = limit;
this.running = 0;
this.queue = [];
}
push(task) {
return new Promise((resolve, reject) => {
this.queue.push({ task, resolve, reject });
this._run();
});
}
_run() {
while (this.running < this.limit && this.queue.length) {
const { task, resolve, reject } = this.queue.shift();
this.running++;
task().then(resolve, reject).finally(() => {
this.running--;
this._run();
});
}
}
}
// 预加载主控 (显示进度,并发下载未缓存资源)
async function startPreload() {
const startBtn = document.getElementById('startPreloadBtn');
const clearBtn = document.getElementById('clearCacheBtn');
const progressBar = document.getElementById('preloadProgressBar');
const percentSpan = document.getElementById('preloadPercent');
const completedSpan = document.getElementById('completedCount');
const totalSpan = document.getElementById('totalCount');
const cachedSkipSpan = document.getElementById('cachedSkipCount');
const currentFileSpan = document.getElementById('currentFileInfo');
const statusMsgSpan = document.getElementById('preloadStatusMsg');
if (startBtn.disabled) {
statusMsgSpan.innerText = '⏳ 预加载进行中,请稍后...';
return;
}
// 禁用按钮
startBtn.disabled = true;
startBtn.innerText = '⏳ 预加载中...';
clearBtn.disabled = true;
statusMsgSpan.innerHTML = '🔍 正在解析瓦片树结构,收集所有资源URL...';
currentFileSpan.innerText = '📡 解析 tileset.json 中,请稍等';
try {
// 1. 收集所有需要缓存的资源URL
const allUrls = await collectAllTileUrls(tilesetUrl);
if (!allUrls.length) {
throw new Error('未找到任何可预加载的资源,请检查 tileset 路径或结构');
}
const total = allUrls.length;
totalSpan.innerText = total;
completedSpan.innerText = '0';
cachedSkipSpan.innerText = '0';
percentSpan.innerText = '0';
progressBar.style.width = '0%';
statusMsgSpan.innerHTML = `📋 解析完成,共发现 ${total} 个资源 (瓦片 + 子tileset) ,正在检查缓存并下载缺失项...`;
currentFileSpan.innerText = '准备就绪,开始预加载队列';
// 2. 预先批量检查哪些资源已经缓存 (加速进度统计)
const cacheStatusMap = new Map();
let cachedCountInitial = 0;
for (let i = 0; i < allUrls.length; i++) {
const url = allUrls[i];
const cacheKey = getCacheKey(url);
const exists = await cacheDb.getItem(cacheKey);
if (exists) {
cacheStatusMap.set(cacheKey, true);
cachedCountInitial++;
} else {
cacheStatusMap.set(cacheKey, false);
}
// 每100个更新下提示,避免界面卡顿
if (i % 100 === 0) {
currentFileSpan.innerText = `🔍 扫描缓存进度: ${i}/${total}`;
await new Promise(r => setTimeout(r, 1));
}
}
let skippedAlready = cachedCountInitial;
cachedSkipSpan.innerText = skippedAlready;
let completed = skippedAlready; // 已缓存跳过的不需要下载
let needDownloadUrls = allUrls.filter(url => !cacheStatusMap.get(getCacheKey(url)));
const needTotal = needDownloadUrls.length;
if (needTotal === 0) {
statusMsgSpan.innerHTML = `🎉 所有资源均已缓存 (${total}个)!无需下载,弱网环境可直接使用。`;
currentFileSpan.innerText = '预加载完成,所有数据已在本地缓存';
progressBar.style.width = '100%';
percentSpan.innerText = '100';
completedSpan.innerText = total;
startBtn.disabled = false;
startBtn.innerText = '🚀 一键预加载全部瓦片';
clearBtn.disabled = false;
return;
}
statusMsgSpan.innerHTML = `💾 需要下载 ${needTotal} 个未缓存资源,正在并发预加载 (并发数6) ...`;
completedSpan.innerText = completed;
// 并发队列控制
const queue = new ConcurrencyQueue(6);
let activeDownloadCount = 0;
// 更新进度的函数
function updateProgress() {
const percent = Math.floor((completed / total) * 100);
percentSpan.innerText = percent;
progressBar.style.width = `${percent}%`;
completedSpan.innerText = completed;
cachedSkipSpan.innerText = skippedAlready;
}
// 下载单个资源 (通过Cesium底层或fetch,确保走缓存拦截并存储)
async function downloadResource(resourceUrl) {
const cacheKey = getCacheKey(resourceUrl);
// 再次确认是否在下载过程中被其他请求缓存 (双重检查)
const already = await cacheDb.getItem(cacheKey);
if (already) {
completed++;
skippedAlready++;
cachedSkipSpan.innerText = skippedAlready;
updateProgress();
return { status: 'cached', url: resourceUrl };
}
// 发起真实请求 —— 采用 Cesium.Resource.fetchArrayBuffer 确保兼容拦截器且正确存储
// 同时支持文本json文件: 对于json我们用text, 但二进制瓦片用arraybuffer,但是拦截器内部存储原始数据,没问题
try {
let responseData = null;
// 判断是否为 json 文件 (tileset.json 或 子json)
if (resourceUrl.endsWith('.json') || resourceUrl.includes('tileset')) {
// 使用 fetch 获取blob,因为拦截器会自动缓存(loadWithXhr会被调用)
const resp = await fetch(resourceUrl);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
responseData = await resp.arrayBuffer(); // 统一存为arraybuffer,拦截器存什么格式都可以
} else {
// 瓦片文件使用 Resource.fetchArrayBuffer 会自动经过我们改造的 loadWithXhr
const resource = new Cesium.Resource({ url: resourceUrl });
responseData = await resource.fetchArrayBuffer();
}
// 手动存储确保写入缓存 (fetch请求同样会经过拦截器并存储, 但为了保险再存一次)
if (responseData) {
await cacheDb.setItem(cacheKey, responseData);
}
completed++;
updateProgress();
return { status: 'downloaded', url: resourceUrl };
} catch (err) {
console.error(`下载失败: ${resourceUrl}`, err);
// 即使失败也要计入进度防止卡死,但标记失败不影响其他,最终显示部分失败
completed++;
updateProgress();
return { status: 'failed', url: resourceUrl, error: err };
}
}
// 批量添加任务到队列,并实时显示当前下载的文件名
let currentIndex = 0;
const downloadPromises = [];
for (const url of needDownloadUrls) {
const task = async () => {
currentFileSpan.innerText = `📥 正在缓存: ${url.split('/').pop()} (${currentIndex+1}/${needTotal})`;
const result = await downloadResource(url);
currentIndex++;
if (result.status === 'failed') {
console.warn(`预加载失败: ${url}`);
}
return result;
};
downloadPromises.push(queue.push(task));
}
// 等待所有下载完成
await Promise.allSettled(downloadPromises);
// 最终统计 (再次检查实际缓存数量)
let finalCachedCount = 0;
for (let i = 0; i < allUrls.length; i++) {
const key = getCacheKey(allUrls[i]);
if (await cacheDb.getItem(key)) finalCachedCount++;
}
const finalPercent = Math.floor((finalCachedCount / total) * 100);
progressBar.style.width = `${finalPercent}%`;
percentSpan.innerText = finalPercent;
completedSpan.innerText = finalCachedCount;
cachedSkipSpan.innerText = finalCachedCount - (needTotal - (needTotal - (currentIndex - (needTotal - finalCachedCount))));
if (finalCachedCount === total) {
statusMsgSpan.innerHTML = '🎉 预加载圆满完成!所有瓦片数据已存入本地缓存,弱网环境可流畅使用。';
currentFileSpan.innerText = '✅ 所有资源均已缓存,请尽情体验离线3D场景。';
} else {
const failCount = total - finalCachedCount;
statusMsgSpan.innerHTML = `⚠️ 预加载完成,但有 ${failCount} 个资源未能缓存(网络问题或路径无效),可再次尝试预加载。`;
currentFileSpan.innerText = `部分资源缓存失败,建议检查网络或重新预加载缺失项。`;
}
document.getElementById('cacheStatus').innerHTML = `💾 缓存已就绪 | 已缓存 ${finalCachedCount}/${total} 个资源`;
} catch (err) {
console.error('预加载过程出错', err);
statusMsgSpan.innerHTML = `❌ 预加载失败: ${err.message},请检查 tileset 地址或网络`;
currentFileSpan.innerText = `错误详情: ${err.message}`;
} finally {
startBtn.disabled = false;
startBtn.innerText = '🚀 一键预加载全部瓦片';
clearBtn.disabled = false;
}
}
// 清理缓存功能增强(附带UI提示)
async function clearAllCache() {
if (confirm('⚠️ 确定要清除所有瓦片缓存吗?清除后下次浏览需要重新下载数据,弱网环境可能影响体验。')) {
const clearBtn = document.getElementById('clearCacheBtn');
const startBtn = document.getElementById('startPreloadBtn');
clearBtn.disabled = true;
startBtn.disabled = true;
document.getElementById('preloadStatusMsg').innerHTML = '🗑️ 正在清除缓存...';
try {
await cacheDb.clear();
document.getElementById('cacheStatus').innerHTML = '🧹 缓存已清空,重新加载会重新下载';
document.getElementById('preloadStatusMsg').innerHTML = '✅ 缓存清除成功!可重新预加载';
// 重置进度显示
document.getElementById('preloadPercent').innerText = '0';
document.getElementById('completedCount').innerText = '0';
document.getElementById('totalCount').innerText = '0';
document.getElementById('cachedSkipCount').innerText = '0';
document.getElementById('preloadProgressBar').style.width = '0%';
document.getElementById('currentFileInfo').innerText = '缓存已清空,点击预加载可重新填充';
} catch (err) {
console.error(err);
document.getElementById('preloadStatusMsg').innerHTML = '❌ 清除缓存失败';
} finally {
clearBtn.disabled = false;
startBtn.disabled = false;
}
}
}
// 绑定UI事件
const startBtnElem = document.getElementById('startPreloadBtn');
const clearBtnElem = document.getElementById('clearCacheBtn');
startBtnElem.addEventListener('click', startPreload);
clearBtnElem.addEventListener('click', clearAllCache);
// 额外优化:显示当前缓存大小概览(可选)
async function updateCacheStats() {
try {
let keys = await cacheDb.keys();
document.getElementById('cacheStatus').innerHTML = `📀 缓存瓦片数: ${keys.length} | 拦截器活跃`;
} catch(e) {}
}
setTimeout(updateCacheStats, 2000);
// 控制台暴露手动清理/预加载方法方便调试
window.preload3DTiles = startPreload;
window.clear3DTilesCache = clearAllCache;
console.log('✅ 预加载模块已就绪,使用一键预加载可缓存全部地形瓦片数据');
})();
</script>
</body>
</html>










网友评论