Three.js 加载大规模点云

在数字孪生、WebGIS、激光雷达、自动驾驶、测绘等场景中,点云数据很容易达到千万、上亿甚至更大的规模。

如果直接把全部点云下载到浏览器,然后一次性解析并创建一个巨大的 THREE.Points,很快就会遇到几个问题:

  • 首屏下载时间很长
  • 主线程解析卡顿
  • 浏览器内存占用过高
  • GPU 上传压力大
  • 显存快速增长
  • 帧率下降,严重时页面甚至崩溃

所以,大规模点云渲染真正要解决的问题并不是:

怎么一次把所有点都画出来?

而是:

怎么只加载、只解析、只上传、只渲染当前视角真正需要的那部分点?

Potree 2.0 就是一套非常典型的大规模点云解决方案。它的核心可以概括为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
离线八叉树预处理
+
运行时视锥剔除
+
屏幕空间 LOD
+
HTTP Range 按需加载
+
Web Worker 解码
+
Point Budget
+
GPU 上传限流
+
LRU 内存管理

大规模点云不能一次性全部加载,我一般会采用类似 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
空间上
→ 八叉树

精度上
→ LOD

可见性上
→ Frustum Culling

网络上
→ HTTP Range

调度上
→ BinaryHeap / Priority Queue

CPU 上
→ Web Worker

线程通信上
→ Transferable

GPU 上
→ BufferGeometry + THREE.Points

数量控制上
→ Point Budget

上传控制上
→ GPU Upload Limit

内存上
→ LRU

最终目的只有一个:

无论原始点云有多少点,都不要让浏览器真正同时处理所有点,而是始终只处理“当前这一帧最值得显示的那部分数据”。

这就是 Three.js 加载大规模点云的核心思路。

本文从 Three.js 的角度,把这条完整链路串起来。


一、普通 THREE.Points 为什么不适合直接加载超大点云?

Three.js 渲染普通点云非常简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const geometry = new THREE.BufferGeometry();

geometry.setAttribute(
"position",
new THREE.Float32BufferAttribute(positions, 3)
);

const points = new THREE.Points(
geometry,
new THREE.PointsMaterial({
size: 2,
vertexColors: true
})
);

scene.add(points);

这种方式对几十万、几百万个点通常没有太大问题。

但如果数据达到一亿点,情况就不一样了。

仅位置属性:

1
2
3
4
5
x + y + z
=
3 × Float32
=
12 Byte / point

如果再加颜色:

1
2
3
RGBA
=
4 Byte / point

一个点大约就是:

1
16 Byte

那么一亿个点,仅基础顶点数据就可能达到:

1
2
100,000,000 × 16 Byte
≈ 1.6 GB

而实际运行时还会有:

  • ArrayBuffer
  • TypedArray
  • BufferGeometry
  • WebGLBuffer
  • 材质、索引、其他属性
  • 浏览器和业务本身的内存

所以,超大点云不能按照“全部加载、全部解析、全部上传 GPU”的思路处理。


二、第一步:离线把点云切成八叉树

Potree 2.0 并不是在浏览器运行时拿原始 LAS / LAZ 重新构建八叉树。

真正的八叉树划分是在离线转换阶段提前完成的。

可以把流程理解成:

1
2
3
4
5
6
7
8
9
10
11
原始 LAS / LAZ / PLY
↓
点云转换工具
↓
空间划分
↓
生成八叉树
↓
生成不同层级的数据
↓
输出 Potree 2.0 数据

Potree 2.0 常见的核心文件是:

1
2
3
metadata.json
hierarchy.bin
octree.bin

这三个文件分别承担不同的职责。


三、metadata.json:描述整个点云数据集

metadata.json 可以理解成整个点云数据集的说明书。

它会记录类似下面的信息:

1
2
3
4
5
6
7
8
9
10
version
points
projection
boundingBox
spacing
scale
offset
attributes
hierarchy
encoding

例如:

  • 整个点云的包围盒有多大
  • 点的位置如何缩放
  • 包含哪些点属性
  • 八叉树层级信息如何组织
  • 数据是否经过 Brotli 编码

浏览器加载 Potree 2.0 数据时,首先会读取这个文件。


四、hierarchy.bin:保存八叉树的目录结构

hierarchy.bin 可以理解成:

八叉树的目录和索引。

它并不主要保存真正的点坐标,而是描述:

1
2
3
4
这个节点是谁
这个节点有几个子节点
这个节点有多少点
这个节点的数据在 octree.bin 的什么位置

在 Potree 2.0 的加载代码中,每个 hierarchy 节点会读取:

1
2
3
4
5
type
childMask
numPoints
byteOffset
byteSize

其中最关键的是:

childMask

八叉树一个节点最多有 8 个子节点。

childMask 使用一个字节中的 8 个 bit 表示哪些子节点存在。

例如:

1
00000001

表示 child 0 存在。

1
00010001

则可以表示多个 child 同时存在。

因此浏览器不需要重新分析全部点坐标来判断八叉树关系,它只需要根据已经生成好的 hierarchy 信息恢复运行时节点结构。

需要特别区分:

1
2
3
4
5
离线阶段
→ 真正构建八叉树并分配点

浏览器阶段
→ 根据 hierarchy.bin 恢复八叉树对象

浏览器中的 new OctreeGeometryNode() 并不意味着浏览器重新给原始点云分树。


五、octree.bin:真正存储所有节点的点数据

Potree 2.0 和早期“一个节点一个 bin 文件”的组织方式不同。

Potree 2.0 会把不同八叉树节点的数据集中存放到:

1
octree.bin

里面可以包含:

1
2
3
4
5
6
7
position
rgba
normal
intensity
classification
gps-time
...

问题来了。

如果 octree.bin 是几个 GB,浏览器难道还是要全部下载吗?

当然不是。

这就需要 HTTP Range。


六、HTTP Range:大文件只取当前需要的一段

HTTP Range 的作用非常简单:

请求一个大文件时,只请求其中某个字节范围。

例如:

1
octree.bin = 5 GB

当前只需要其中:

1
2
3
第 1,000,000 字节
到
第 1,200,000 字节

可以发送:

1
Range: bytes=1000000-1200000

服务器只返回这一段。

Potree 2.0 正是利用这种方式实现节点级按需加载。


七、byteOffset 和 byteSize 是什么?

每个八叉树节点都会记录:

1
2
byteOffset
byteSize

它们告诉浏览器这个节点的数据在 octree.bin 的哪一段。

例如:

1
2
byteOffset = 1000
byteSize = 500

那么这个节点对应的数据就是:

1
1000 ~ 1499

于是可以得到:

1
2
const first = byteOffset;
const last = byteOffset + byteSize - 1;

最终请求:

1
Range: bytes=1000-1499

所以可以简单记成:

1
2
byteOffset = 起点
byteSize = 长度

这样即使整个点云有几十 GB,浏览器当前也可能只请求几百 KB 或几 MB。


八、视锥剔除:先排除完全看不到的节点

八叉树节点非常多。

如果相机只能看到场景的一小部分,就没必要处理视野之外的节点。

Potree 会给每个节点维护:

1
2
BoundingBox
BoundingSphere

然后构建当前相机的 Frustum。

运行时判断:

1
frustum.intersectsBox(node.boundingBox)

如果一个节点的包围盒完全在相机视锥外:

1
2
3
4
不加载
不解析
不上 GPU
不渲染

重点是:

Potree 不是一个点一个点做视锥判断。

它是直接拿一个八叉树节点的 BoundingBox 去判断。

如果一个节点包含 5 万个点,只需要一次包围盒相交测试,就可能一次排除掉这 5 万个点。

这就是空间索引的价值。


九、Potree 的视锥是怎么构建的?

从 Three.js 的变换流程看,一个点从点云局部坐标进入裁剪空间需要:

1
2
3
4
5
Projection
×
View
×
Model

也就是:

1
P × V × M

Potree 会组合:

1
2
3
4
5
camera.projectionMatrix
×
camera.matrixWorldInverse
×
pointCloud.matrixWorld

然后:

1
new Frustum().setFromProjectionMatrix(frustumMatrix)

得到一个点云局部坐标系下的视锥。

这样做的好处是:

节点的 boundingBox 本来就在点云局部坐标系中,因此可以直接:

1
frustum.intersectsBox(node.boundingBox)

而不需要每一帧把大量八叉树节点的包围盒都转到世界坐标。


十、LOD:决定八叉树要细分到哪一层

视锥剔除解决的是:

这个节点看不看得见?

LOD 解决的是:

这个节点需要精细到什么程度?

Potree 并不是简单按照:

1
2
距离 < 100m → Level 5
距离 < 50m → Level 6

来切换层级。

它关注的是:

节点投影到屏幕后占多少像素。

对于透视相机,可以把核心公式简化成:

1
2
3
4
5
6
7
screenPixelRadius
≈
节点实际半径
÷
节点到相机的距离
×
相机投影系数

源码中的思路类似:

1
2
3
4
5
6
projectionFactor =
halfHeight /
(Math.tan(fov / 2) * distance);

screenPixelRadius =
radius * projectionFactor;

因此:

1
2
3
4
5
6
7
8
节点越大
→ 屏幕占比越大

节点离相机越近
→ 屏幕占比越大

节点离相机越远
→ 屏幕占比越小

如果:

1
screenPixelRadius < minNodePixelSize

说明这个节点已经小到没有必要继续细分。

例如:

1
2
3
4
5
当前节点占屏幕 300 px
→ 继续加载更精细的 child

当前节点占屏幕 10 px
→ 不再继续往下一层

所以 Potree 的 LOD 本质上是一个屏幕空间 LOD。


十一、为什么不能只看距离?

因为节点本身大小也不同。

例如:

1
2
3
4
5
6
7
节点 A
半径 100m
距离相机 500m

节点 B
半径 2m
距离相机 100m

B 虽然更近,但 A 的空间范围更大。

最后谁对屏幕画面影响更大,不能只看 distance。

因此更合理的是结合:

1
2
3
4
5
6
7
节点半径
+
相机距离
+
相机 FOV
+
屏幕尺寸

最后统一转换成:

1
screenPixelRadius

十二、BinaryHeap:让重要节点优先处理

符合 LOD 条件的节点可能仍然很多。

此时 Potree 不会简单按照八叉树顺序加载,而是给节点计算一个权重:

1
weight

大体上:

1
2
3
4
5
屏幕占比越大
+
距离越近
=
优先级越高

例如:

1
2
3
Node A:占屏幕 500px
Node B:占屏幕 200px
Node C:占屏幕 20px

处理顺序更接近:

1
2
3
4
5
A
↓
B
↓
C

这里使用的 BinaryHeap 本质上就是一种实现优先级队列的数据结构。

普通队列强调:

1
谁先进去,谁先出来

优先级队列强调:

1
谁更重要,谁先出来

这会让用户当前正在看的区域优先变清晰。


十三、Point Budget:限制当前画面最多显示多少点

即使通过了视锥剔除和 LOD,也不能无限增加点数量。

因此 Potree 有:

1
Point Budget

可以理解成:

当前一次渲染更新最多允许选中多少点。

例如:

1
pointBudget = 1,000,000

当前已经选了:

1
950,000

下一个节点:

1
100,000

那么:

1
2
3
950,000 + 100,000
=
1,050,000

已经超过预算,就不能继续无节制扩张。

所以:

1
2
3
4
5
整个数据集:
1亿点

当前视角真正参与渲染:
可能只有100万点左右

这才是浏览器能够显示超大规模点云的关键。


十四、节点什么时候真正触发网络加载?

这部分非常重要。

不是节点一进入八叉树就立刻发请求。

一个节点通常要经历:

1
2
3
4
5
6
7
8
9
10
11
12
13
进入候选队列
↓
Point Budget 判断
↓
最大 Level 判断
↓
视锥剔除
↓
LOD 判断
↓
优先级调度
↓
确认当前确实需要

如果这个节点:

1
2
3
需要显示
+
还没有 loaded

它会先进入:

1
unloadedGeometry

等当前一轮可见性判断结束,再根据并发限制挑选一部分节点调用:

1
node.load()

然后进入真正的网络加载流程:

1
2
3
4
5
6
7
8
9
node.load()
↓
NodeLoader.load()
↓
读取 byteOffset / byteSize
↓
HTTP Range
↓
octree.bin

所以可以概括成:

只有通过视锥、LOD、Point Budget 等筛选,并且当前需要显示但尚未加载的节点,才会在并发额度允许时真正触发网络请求。


十五、Web Worker:点数据解析不能堵主线程

HTTP Range 请求回来以后得到的是:

1
ArrayBuffer

其中可能包含:

1
2
3
4
5
6
position
rgba
normal
intensity
classification
...

如果直接在主线程中解析几十万甚至几百万个点,可能造成明显卡顿。

因此 Potree 会把解码工作交给 Web Worker:

1
2
3
4
5
6
7
主线程
↓
ArrayBuffer
↓
Worker
↓
解析二进制

Worker 内会将原始数据转换为适合 Three.js / GPU 使用的 TypedArray。

例如 position:

1
Float32Array

color:

1
Uint8Array

其他属性也会转换成对应的数据缓冲。

这样主线程仍然可以继续:

  • 响应鼠标事件
  • 更新相机
  • 执行动画
  • 渲染 UI
  • 处理 Three.js 场景

十六、Transferable:Worker 之间传大数据为什么不慢?

点云里的 ArrayBuffer 很大。

如果主线程把一个 200MB 的 ArrayBuffer 复制给 Worker:

1
2
3
4
5
6
7
主线程
200MB

↓ copy

Worker
200MB

复制本身就会消耗大量时间和内存。

因此 Web Worker 支持 Transferable。

例如:

1
2
3
worker.postMessage(message, [
message.buffer
]);

第二个参数就是 transfer list。

它的核心思想是:

不重新复制 ArrayBuffer,而是把这块内存的所有权转移给 Worker。

Worker 解码完成以后,也可以把 position、color 等 ArrayBuffer 再 transfer 回主线程。

因此大规模二进制数据在线程之间传递时,可以显著降低数据复制开销。


十七、Worker 解码完成后如何进入 Three.js?

Worker 返回的数据最终会被包装成 Three.js 的:

1
BufferAttribute

然后放进:

1
BufferGeometry

例如:

1
2
3
4
5
6
7
8
9
const geometry = new THREE.BufferGeometry();

geometry.setAttribute(
"position",
new THREE.BufferAttribute(
new Float32Array(buffer),
3
)
);

颜色类似:

1
2
3
4
5
6
7
8
geometry.setAttribute(
"rgba",
new THREE.BufferAttribute(
new Uint8Array(buffer),
4,
true
)
);

所以这一步可以总结为:

1
2
3
4
5
6
7
8
9
octree.bin 二进制数据
↓
Web Worker
↓
TypedArray
↓
BufferAttribute
↓
BufferGeometry

十八、符合条件的节点最终怎么渲染?

一个已经加载并解码完成的八叉树节点,最终会转换成:

1
2
3
4
new THREE.Points(
geometryNode.geometry,
material
);

也就是说:

1
2
3
4
5
一个八叉树节点
≈
一个 BufferGeometry
+
一个 THREE.Points

这里非常重要:

不是:

1
2
3
一个点
=
一个 Mesh

也不是创建几十万个 Object3D。

而是把一个节点中的大量点统一放进 BufferGeometry,然后通过 THREE.Points 批量绘制。

最终 GPU 做的本质上仍然是一次点图元绘制。


十九、为什么 Potree 不直接使用 THREE.PointsMaterial?

Three.js 自带:

1
THREE.PointsMaterial

可以满足简单点云需求。

但 Potree 的点云材质要复杂得多。

它还需要处理:

1
2
3
4
5
6
7
8
9
动态点大小
RGB 着色
高度着色
Intensity
Classification
裁剪
EDL
拾取
LOD 相关参数

因此 Potree 会使用自己的:

1
PointCloudMaterial

最终组合可以理解成:

1
2
3
4
5
THREE.Points
+
BufferGeometry
+
Potree PointCloudMaterial

二十、GPU 上传也要限流

一个常见误区是:

网络下载越快、Worker 解码越快越好。

实际上不是。

如果一帧突然有 30 个大节点同时解析完成,然后一起:

1
2
3
4
5
创建 BufferGeometry
↓
创建 WebGLBuffer
↓
上传 GPU

这一帧很可能出现严重卡顿。

所以 Potree 还会限制:

1
每一次更新允许进入 GPU 的节点数量

例如:

1
MAX_LOADS_TO_GPU = 2

因此整个系统实际上有多层限流:

1
2
3
4
5
6
7
节点网络加载并发限制
↓
Worker 解码
↓
GPU 上传数量限制
↓
Point Budget

它的目标不是某一个阶段跑到最快,而是保证整个渲染链路稳定。


二十一、LRU:离开的区域不能永远占着内存

随着相机不断移动:

1
2
3
4
5
6
7
区域 A
↓
区域 B
↓
区域 C
↓
区域 D

浏览器加载的节点会越来越多。

如果之前加载过的数据永远不释放:

1
2
3
CPU 内存持续增长
+
GPU 显存持续增长

最终页面还是会崩。

因此 Potree 会使用 LRU 管理缓存。

LRU:

1
Least Recently Used

即:

最近最少使用的资源优先淘汰。

当缓存超过预算后,那些长时间没有被使用的节点会被释放:

1
2
3
4
5
BufferGeometry.dispose()
↓
释放 WebGLBuffer
↓
节点恢复 unloaded 状态

以后如果用户重新移动到该区域,再重新加载即可。

所以完整生命周期是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
需要节点
↓
网络加载
↓
Worker 解码
↓
GPU 上传
↓
显示
↓
相机离开
↓
进入缓存
↓
长期不使用
↓
LRU 淘汰
↓
释放资源

二十二、完整运行流程

将所有东西串起来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
                     离线阶段

LAS / LAZ / PLY
↓
点云转换工具
↓
构建八叉树 + LOD
↓
┌──────────────────────────┐
│ metadata.json │
│ hierarchy.bin │
│ octree.bin │
└──────────────────────────┘


浏览器阶段

读取 metadata.json
↓
读取 hierarchy.bin
↓
恢复八叉树节点结构
↓
从 Root 开始
↓
视锥剔除
↓
计算 screenPixelRadius
↓
LOD 判断
↓
Point Budget
↓
BinaryHeap 优先级调度
↓
发现需要的节点尚未加载
↓
byteOffset + byteSize
↓
HTTP Range
↓
octree.bin
↓
ArrayBuffer
↓
Transferable
↓
Web Worker
↓
position / color / ...
↓
BufferGeometry
↓
THREE.Points
↓
GPU
↓
PointCloudMaterial
↓
最终画面

相机移动后
↓
重新计算可见节点
↓
LRU 淘汰长期不用资源
作者

wangyawei

发布于

2026-03-21

更新于

2026-09-11

许可协议