美文网首页
Cesium模型瓦片一键缓存本地

Cesium模型瓦片一键缓存本地

作者: 伊夫_艾尔斯 | 来源:发表于2026-04-07 16:43 被阅读0次

直接上示例代码(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>

相关文章

网友评论

      本文标题:Cesium模型瓦片一键缓存本地

      本文链接:https://www.haomeiwen.com/subject/ltddestx.html