Three.js 数据分块与懒加载

308 阅读4分钟

引言

在使用 three.js 构建大型 3D 场景时,比如虚拟城市、大型工业模型或者复杂的游戏世界,你可能会遇到性能瓶颈。场景中的海量数据会导致加载时间过长,内存占用过高,甚至引发浏览器崩溃。数据分块与懒加载技术正是解决这些问题的有效手段。

数据分块

什么是数据分块?

数据分块就是把一个庞大的 3D 场景按照特定规则,划分成多个较小的、独立的部分,也就是数据块。这样一来,就不用一次性加载整个场景了。

分块策略

1. 基于空间的分块(网格划分)

这种策略是将 3D 空间划分成大小相同的网格单元,每一个单元就是一个数据块。在城市模型中,我们可以按照街区来划分场景。

下面是一个简单的网格分块实现示例:

// 场景分块函数
function createSpatialChunks(sceneSize, chunkSize) {
    const chunks = [];
    const chunkCount = Math.floor(sceneSize / chunkSize);
    
    for (let x = 0; x < chunkCount; x++) {
        for (let y = 0; y < chunkCount; y++) {
            for (let z = 0; z < chunkCount; z++) {
                // 定义每个块的边界
                const chunk = {
                    position: new THREE.Vector3(x * chunkSize, y * chunkSize, z * chunkSize),
                    size: chunkSize,
                    objects: [] // 存储属于该块的3D对象
                };
                chunks.push(chunk);
            }
        }
    }
    
    return chunks;
}

2. 基于主题的分块

按照功能或者类型对场景进行划分。在一个工厂模型里,可以把机器、管道、建筑等分别划分为不同的数据块。

实现分块加载

当完成场景分块后,我们还需要对数据进行预处理,把每个块保存成单独的文件。以下是一个处理单个块数据导出的示例:

// 导出单个块的数据
function exportChunk(chunk, filename) {
    // 创建一个临时场景来存储当前块的对象
    const tempScene = new THREE.Scene();
    chunk.objects.forEach(object => {
        tempScene.add(object.clone());
    });
    
    // 使用THREE.GLTFExporter导出为GLTF格式
    const exporter = new THREE.GLTFExporter();
    exporter.parse(tempScene, (gltf) => {
        const data = JSON.stringify(gltf);
        const blob = new Blob([data], {type: 'application/json'});
        const url = URL.createObjectURL(blob);
        
        // 下载文件
        const link = document.createElement('a');
        link.href = url;
        link.download = filename;
        link.click();
    }, {binary: false});
}

懒加载技术

什么是懒加载?

懒加载,也就是延迟加载,指的是在用户需要某个数据块的时候才去加载它,而不是在一开始就把所有数据都加载好。在 3D 场景中,通常是当数据块进入视锥体或者靠近相机时,才进行加载。

实现懒加载的关键技术

1. 视锥体剔除检测

视锥体剔除是判断物体是否在相机视场范围内的一种技术。借助它,我们能够确定哪些数据块需要被加载。

// 检查块是否在视锥体内
function isChunkInFrustum(chunk, camera) {
    const frustum = new THREE.Frustum();
    const cameraViewProjectionMatrix = new THREE.Matrix4();
    
    camera.updateMatrixWorld();
    camera.updateProjectionMatrix();
    cameraViewProjectionMatrix.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
    frustum.setFromProjectionMatrix(cameraViewProjectionMatrix);
    
    // 为块创建一个包围盒
    const box = new THREE.Box3(
        new THREE.Vector3(
            chunk.position.x,
            chunk.position.y,
            chunk.position.z
        ),
        new THREE.Vector3(
            chunk.position.x + chunk.size,
            chunk.position.y + chunk.size,
            chunk.position.z + chunk.size
        )
    );
    
    return frustum.intersectsBox(box);
}

2. 距离检测

除了视锥体剔除,我们还可以根据数据块与相机的距离来决定是否加载。离相机远的数据块可以延迟加载或者降低细节程度。

// 计算块与相机的距离
function getDistanceFromCamera(chunk, camera) {
    const chunkCenter = new THREE.Vector3(
        chunk.position.x + chunk.size / 2,
        chunk.position.y + chunk.size / 2,
        chunk.position.z + chunk.size / 2
    );
    
    return chunkCenter.distanceTo(camera.position);
}

懒加载管理器

下面是一个简单的懒加载管理器实现,它能够根据相机位置动态加载和卸载数据块:

class ChunkLoader {
    constructor(scene, camera, chunkSize, loadDistance, unloadDistance) {
        this.scene = scene;
        this.camera = camera;
        this.chunkSize = chunkSize;
        this.loadDistance = loadDistance;
        this.unloadDistance = unloadDistance;
        this.loadedChunks = new Map(); // 已加载的块
        this.chunks = []; // 所有块的元数据
        this.loadingQueue = []; // 加载队列
        this.exporter = new THREE.GLTFExporter();
        this.loader = new THREE.GLTFLoader();
    }
    
    // 初始化块
    initChunks(sceneSize) {
        this.chunks = createSpatialChunks(sceneSize, this.chunkSize);
    }
    
    // 更新加载状态
    update() {
        // 检查是否有需要加载的块
        this.chunks.forEach(chunk => {
            const distance = getDistanceFromCamera(chunk, this.camera);
            
            // 如果块在加载距离内且未加载
            if (distance < this.loadDistance && !this.loadedChunks.has(chunk.id)) {
                this.queueChunkForLoading(chunk);
            }
            // 如果块在卸载距离外且已加载
            else if (distance > this.unloadDistance && this.loadedChunks.has(chunk.id)) {
                this.unloadChunk(chunk);
            }
        });
        
        // 处理加载队列
        this.processLoadingQueue();
    }
    
    // 将块加入加载队列
    queueChunkForLoading(chunk) {
        if (!this.loadingQueue.includes(chunk)) {
            this.loadingQueue.push(chunk);
        }
    }
    
    // 处理加载队列
    processLoadingQueue() {
        // 一次只加载一个块,避免性能峰值
        if (this.loadingQueue.length > 0) {
            const chunk = this.loadingQueue.shift();
            this.loadChunk(chunk);
        }
    }
    
    // 加载块
    loadChunk(chunk) {
        const filename = `chunk_${chunk.position.x}_${chunk.position.y}_${chunk.position.z}.gltf`;
        
        this.loader.load(
            filename,
            (gltf) => {
                // 设置块的位置
                gltf.scene.position.copy(chunk.position);
                
                // 将模型添加到场景
                this.scene.add(gltf.scene);
                
                // 记录已加载的块
                this.loadedChunks.set(chunk.id, gltf.scene);
                
                // 可以在这里触发加载完成事件
            },
            (progress) => {
                // 加载进度回调
            },
            (error) => {
                console.error('加载块出错:', error);
            }
        );
    }
    
    // 卸载块
    unloadChunk(chunk) {
        if (this.loadedChunks.has(chunk.id)) {
            const chunkObject = this.loadedChunks.get(chunk.id);
            this.scene.remove(chunkObject);
            this.loadedChunks.delete(chunk.id);
        }
    }
}

优化建议

  1. 预加载策略:可以提前加载相机移动方向前方的数据块,以此减少用户等待时间。
  1. 多级细节(LOD) :对于远距离的数据块,可以加载低精度版本,从而节省资源。
  1. 缓存机制:已经加载过的数据块可以进行缓存,这样在用户返回时就无需重新加载。
  1. 渐进式加载:先加载低细节模型,然后再逐步加载更高细节的内容。

总结

通过数据分块和懒加载技术,我们能够显著提升大型 three.js 应用的性能,减少初始加载时间,降低内存占用。这种技术特别适合虚拟游览、大型数据可视化以及 3D 游戏等场景。

希望这篇文章能帮助你掌握 three.js 中的数据分块与懒加载技术!如果有任何问题,欢迎留言讨论。