Three.js 加载大规模点云
在数字孪生、WebGIS、激光雷达、自动驾驶、测绘等场景中,点云数据很容易达到千万、上亿甚至更大的规模。
如果直接把全部点云下载到浏览器,然后一次性解析并创建一个巨大的 THREE.Points,很快就会遇到几个问题:
- 首屏下载时间很长
- 主线程解析卡顿
- 浏览器内存占用过高
- GPU 上传压力大
- 显存快速增长
- 帧率下降,严重时页面甚至崩溃
所以,大规模点云渲染真正要解决的问题并不是:
怎么一次把所有点都画出来?
而是:
怎么只加载、只解析、只上传、只渲染当前视角真正需要的那部分点?
Potree 2.0 就是一套非常典型的大规模点云解决方案。它的核心可以概括为:
1 | 离线八叉树预处理 |
大规模点云不能一次性全部加载,我一般会采用类似 Potree 的流式加载方案。首先在离线阶段通过八叉树对原始点云做空间划分,并生成多层 LOD。运行时从根节点开始,根据相机视锥先做节点级剔除,再根据节点包围球投影到屏幕后的像素大小决定是否继续向下细分。
对当前需要的节点,通过 Point Budget 控制单帧可见点总量,并通过优先级队列优先处理屏幕占比大、离相机近的节点。
Potree 2.0 会把节点数据集中存储在 octree.bin,每个节点记录 byteOffset 和 byteSize,浏览器通过 HTTP Range 只请求当前节点对应的那一段数据,而不是下载完整点云文件。
请求回来的 ArrayBuffer 会通过 Transferable 交给 Web Worker 解码,避免阻塞主线程。解码完成后转成 BufferAttribute 和 BufferGeometry,再通过 THREE.Points 批量上传 GPU 和渲染。
同时还需要限制每帧 GPU 上传节点数量,并通过 LRU 淘汰长时间不用的节点,控制 CPU 内存和 GPU 显存。
所以核心就是:八叉树 + LOD + 视锥剔除 + HTTP Range + Web Worker + Transferable + Point Budget + GPU 上传限流 + LRU。
大规模点云优化不是单独某一项技术。
它是一整套从磁盘到 GPU 的流式系统:
1 | 空间上 |
最终目的只有一个:
无论原始点云有多少点,都不要让浏览器真正同时处理所有点,而是始终只处理“当前这一帧最值得显示的那部分数据”。
这就是 Three.js 加载大规模点云的核心思路。
本文从 Three.js 的角度,把这条完整链路串起来。
一、普通 THREE.Points 为什么不适合直接加载超大点云?
Three.js 渲染普通点云非常简单:
1 | const geometry = new THREE.BufferGeometry(); |
这种方式对几十万、几百万个点通常没有太大问题。
但如果数据达到一亿点,情况就不一样了。
仅位置属性:
1 | x + y + z |
如果再加颜色:
1 | RGBA |
一个点大约就是:
1 | 16 Byte |
那么一亿个点,仅基础顶点数据就可能达到:
1 | 100,000,000 × 16 Byte |
而实际运行时还会有:
- ArrayBuffer
- TypedArray
- BufferGeometry
- WebGLBuffer
- 材质、索引、其他属性
- 浏览器和业务本身的内存
所以,超大点云不能按照“全部加载、全部解析、全部上传 GPU”的思路处理。
二、第一步:离线把点云切成八叉树
Potree 2.0 并不是在浏览器运行时拿原始 LAS / LAZ 重新构建八叉树。
真正的八叉树划分是在离线转换阶段提前完成的。
可以把流程理解成:
1 | 原始 LAS / LAZ / PLY |
Potree 2.0 常见的核心文件是:
1 | metadata.json |
这三个文件分别承担不同的职责。
三、metadata.json:描述整个点云数据集
metadata.json 可以理解成整个点云数据集的说明书。
它会记录类似下面的信息:
1 | version |
例如:
- 整个点云的包围盒有多大
- 点的位置如何缩放
- 包含哪些点属性
- 八叉树层级信息如何组织
- 数据是否经过 Brotli 编码
浏览器加载 Potree 2.0 数据时,首先会读取这个文件。
四、hierarchy.bin:保存八叉树的目录结构
hierarchy.bin 可以理解成:
八叉树的目录和索引。
它并不主要保存真正的点坐标,而是描述:
1 | 这个节点是谁 |
在 Potree 2.0 的加载代码中,每个 hierarchy 节点会读取:
1 | type |
其中最关键的是:
childMask
八叉树一个节点最多有 8 个子节点。
childMask 使用一个字节中的 8 个 bit 表示哪些子节点存在。
例如:
1 | 00000001 |
表示 child 0 存在。
1 | 00010001 |
则可以表示多个 child 同时存在。
因此浏览器不需要重新分析全部点坐标来判断八叉树关系,它只需要根据已经生成好的 hierarchy 信息恢复运行时节点结构。
需要特别区分:
1 | 离线阶段 |
浏览器中的 new OctreeGeometryNode() 并不意味着浏览器重新给原始点云分树。
五、octree.bin:真正存储所有节点的点数据
Potree 2.0 和早期“一个节点一个 bin 文件”的组织方式不同。
Potree 2.0 会把不同八叉树节点的数据集中存放到:
1 | octree.bin |
里面可以包含:
1 | position |
问题来了。
如果 octree.bin 是几个 GB,浏览器难道还是要全部下载吗?
当然不是。
这就需要 HTTP Range。
六、HTTP Range:大文件只取当前需要的一段
HTTP Range 的作用非常简单:
请求一个大文件时,只请求其中某个字节范围。
例如:
1 | octree.bin = 5 GB |
当前只需要其中:
1 | 第 1,000,000 字节 |
可以发送:
1 | Range: bytes=1000000-1200000 |
服务器只返回这一段。
Potree 2.0 正是利用这种方式实现节点级按需加载。
七、byteOffset 和 byteSize 是什么?
每个八叉树节点都会记录:
1 | byteOffset |
它们告诉浏览器这个节点的数据在 octree.bin 的哪一段。
例如:
1 | byteOffset = 1000 |
那么这个节点对应的数据就是:
1 | 1000 ~ 1499 |
于是可以得到:
1 | const first = byteOffset; |
最终请求:
1 | Range: bytes=1000-1499 |
所以可以简单记成:
1 | byteOffset = 起点 |
这样即使整个点云有几十 GB,浏览器当前也可能只请求几百 KB 或几 MB。
八、视锥剔除:先排除完全看不到的节点
八叉树节点非常多。
如果相机只能看到场景的一小部分,就没必要处理视野之外的节点。
Potree 会给每个节点维护:
1 | BoundingBox |
然后构建当前相机的 Frustum。
运行时判断:
1 | frustum.intersectsBox(node.boundingBox) |
如果一个节点的包围盒完全在相机视锥外:
1 | 不加载 |
重点是:
Potree 不是一个点一个点做视锥判断。
它是直接拿一个八叉树节点的 BoundingBox 去判断。
如果一个节点包含 5 万个点,只需要一次包围盒相交测试,就可能一次排除掉这 5 万个点。
这就是空间索引的价值。
九、Potree 的视锥是怎么构建的?
从 Three.js 的变换流程看,一个点从点云局部坐标进入裁剪空间需要:
1 | Projection |
也就是:
1 | P × V × M |
Potree 会组合:
1 | camera.projectionMatrix |
然后:
1 | new Frustum().setFromProjectionMatrix(frustumMatrix) |
得到一个点云局部坐标系下的视锥。
这样做的好处是:
节点的 boundingBox 本来就在点云局部坐标系中,因此可以直接:
1 | frustum.intersectsBox(node.boundingBox) |
而不需要每一帧把大量八叉树节点的包围盒都转到世界坐标。
十、LOD:决定八叉树要细分到哪一层
视锥剔除解决的是:
这个节点看不看得见?
LOD 解决的是:
这个节点需要精细到什么程度?
Potree 并不是简单按照:
1 | 距离 < 100m → Level 5 |
来切换层级。
它关注的是:
节点投影到屏幕后占多少像素。
对于透视相机,可以把核心公式简化成:
1 | screenPixelRadius |
源码中的思路类似:
1 | projectionFactor = |
因此:
1 | 节点越大 |
如果:
1 | screenPixelRadius < minNodePixelSize |
说明这个节点已经小到没有必要继续细分。
例如:
1 | 当前节点占屏幕 300 px |
所以 Potree 的 LOD 本质上是一个屏幕空间 LOD。
十一、为什么不能只看距离?
因为节点本身大小也不同。
例如:
1 | 节点 A |
B 虽然更近,但 A 的空间范围更大。
最后谁对屏幕画面影响更大,不能只看 distance。
因此更合理的是结合:
1 | 节点半径 |
最后统一转换成:
1 | screenPixelRadius |
十二、BinaryHeap:让重要节点优先处理
符合 LOD 条件的节点可能仍然很多。
此时 Potree 不会简单按照八叉树顺序加载,而是给节点计算一个权重:
1 | weight |
大体上:
1 | 屏幕占比越大 |
例如:
1 | Node A:占屏幕 500px |
处理顺序更接近:
1 | A |
这里使用的 BinaryHeap 本质上就是一种实现优先级队列的数据结构。
普通队列强调:
1 | 谁先进去,谁先出来 |
优先级队列强调:
1 | 谁更重要,谁先出来 |
这会让用户当前正在看的区域优先变清晰。
十三、Point Budget:限制当前画面最多显示多少点
即使通过了视锥剔除和 LOD,也不能无限增加点数量。
因此 Potree 有:
1 | Point Budget |
可以理解成:
当前一次渲染更新最多允许选中多少点。
例如:
1 | pointBudget = 1,000,000 |
当前已经选了:
1 | 950,000 |
下一个节点:
1 | 100,000 |
那么:
1 | 950,000 + 100,000 |
已经超过预算,就不能继续无节制扩张。
所以:
1 | 整个数据集: |
这才是浏览器能够显示超大规模点云的关键。
十四、节点什么时候真正触发网络加载?
这部分非常重要。
不是节点一进入八叉树就立刻发请求。
一个节点通常要经历:
1 | 进入候选队列 |
如果这个节点:
1 | 需要显示 |
它会先进入:
1 | unloadedGeometry |
等当前一轮可见性判断结束,再根据并发限制挑选一部分节点调用:
1 | node.load() |
然后进入真正的网络加载流程:
1 | node.load() |
所以可以概括成:
只有通过视锥、LOD、Point Budget 等筛选,并且当前需要显示但尚未加载的节点,才会在并发额度允许时真正触发网络请求。
十五、Web Worker:点数据解析不能堵主线程
HTTP Range 请求回来以后得到的是:
1 | ArrayBuffer |
其中可能包含:
1 | position |
如果直接在主线程中解析几十万甚至几百万个点,可能造成明显卡顿。
因此 Potree 会把解码工作交给 Web Worker:
1 | 主线程 |
Worker 内会将原始数据转换为适合 Three.js / GPU 使用的 TypedArray。
例如 position:
1 | Float32Array |
color:
1 | Uint8Array |
其他属性也会转换成对应的数据缓冲。
这样主线程仍然可以继续:
- 响应鼠标事件
- 更新相机
- 执行动画
- 渲染 UI
- 处理 Three.js 场景
十六、Transferable:Worker 之间传大数据为什么不慢?
点云里的 ArrayBuffer 很大。
如果主线程把一个 200MB 的 ArrayBuffer 复制给 Worker:
1 | 主线程 |
复制本身就会消耗大量时间和内存。
因此 Web Worker 支持 Transferable。
例如:
1 | worker.postMessage(message, [ |
第二个参数就是 transfer list。
它的核心思想是:
不重新复制 ArrayBuffer,而是把这块内存的所有权转移给 Worker。
Worker 解码完成以后,也可以把 position、color 等 ArrayBuffer 再 transfer 回主线程。
因此大规模二进制数据在线程之间传递时,可以显著降低数据复制开销。
十七、Worker 解码完成后如何进入 Three.js?
Worker 返回的数据最终会被包装成 Three.js 的:
1 | BufferAttribute |
然后放进:
1 | BufferGeometry |
例如:
1 | const geometry = new THREE.BufferGeometry(); |
颜色类似:
1 | geometry.setAttribute( |
所以这一步可以总结为:
1 | octree.bin 二进制数据 |
十八、符合条件的节点最终怎么渲染?
一个已经加载并解码完成的八叉树节点,最终会转换成:
1 | new THREE.Points( |
也就是说:
1 | 一个八叉树节点 |
这里非常重要:
不是:
1 | 一个点 |
也不是创建几十万个 Object3D。
而是把一个节点中的大量点统一放进 BufferGeometry,然后通过 THREE.Points 批量绘制。
最终 GPU 做的本质上仍然是一次点图元绘制。
十九、为什么 Potree 不直接使用 THREE.PointsMaterial?
Three.js 自带:
1 | THREE.PointsMaterial |
可以满足简单点云需求。
但 Potree 的点云材质要复杂得多。
它还需要处理:
1 | 动态点大小 |
因此 Potree 会使用自己的:
1 | PointCloudMaterial |
最终组合可以理解成:
1 | THREE.Points |
二十、GPU 上传也要限流
一个常见误区是:
网络下载越快、Worker 解码越快越好。
实际上不是。
如果一帧突然有 30 个大节点同时解析完成,然后一起:
1 | 创建 BufferGeometry |
这一帧很可能出现严重卡顿。
所以 Potree 还会限制:
1 | 每一次更新允许进入 GPU 的节点数量 |
例如:
1 | MAX_LOADS_TO_GPU = 2 |
因此整个系统实际上有多层限流:
1 | 节点网络加载并发限制 |
它的目标不是某一个阶段跑到最快,而是保证整个渲染链路稳定。
二十一、LRU:离开的区域不能永远占着内存
随着相机不断移动:
1 | 区域 A |
浏览器加载的节点会越来越多。
如果之前加载过的数据永远不释放:
1 | CPU 内存持续增长 |
最终页面还是会崩。
因此 Potree 会使用 LRU 管理缓存。
LRU:
1 | Least Recently Used |
即:
最近最少使用的资源优先淘汰。
当缓存超过预算后,那些长时间没有被使用的节点会被释放:
1 | BufferGeometry.dispose() |
以后如果用户重新移动到该区域,再重新加载即可。
所以完整生命周期是:
1 | 需要节点 |
二十二、完整运行流程
将所有东西串起来:
1 | 离线阶段 |
Three.js 加载大规模点云