欢迎光临
我们一直在努力

Android 地图几万要素卡成幻灯片?顶点缓冲批上传与显存复用

每次 GL 调用都有固定开销,几万个要素各自上传必然卡爆。按批合并顶点、复用缓冲、一次 bulk 拷贝,才是移动端地图流畅的底气。

前言

在 OpenGL 里画东西,很多人以为"画的数量越多越慢",但真正的瓶颈往往不是绘制本身,而是调用次数。

想象一个图层里有 5 万个要素,如果每个要素都自己上传一次顶点缓冲、自己发一次绘制调用,那就是 5 万次 GL 函数调用。每一次都有上下文切换、驱动开销、状态绑定,移动端 GPU 根本扛不住,帧率直接崩。

但如果你把 5 万个要素的顶点合并成一批,只上传一次、只画一次,那 GPU 就轻松了。问题是怎么合并?合并到什么粒度?数据更新时又怎么只重传变了的那部分?这套引擎给出的答案很工程化:按批分组 + 缓冲复用 + 批量拷贝。本篇拆开讲讲。

一、根因:GL 的固定开销,决定了"调用越少越好"

OpenGL 的每次绘制调用(glDrawElements)背后,驱动要做很多固定的事:校验状态、绑定对象、切换上下文。这些开销跟数据量无关,是"每调一次都收的过路费"。

所以优化铁律是:减少调用次数,而不是减少数据量。与其让 5 万个要素各自画,不如把它们拼成 500 个批次、每批 100 个要素,调用次数直接降两个数量级。

但"合并"不是无脑把全部要素塞进一个缓冲。因为地图要素是可变的——用户可能编辑、增删某个要素。如果全塞一起,改一个就要重传整个缓冲,反而更糟。所以要在"批次粒度"和"更新成本"之间找平衡。

GL 的瓶颈是"调用次数"而非"数据量",铁律就是减少调用。

二、解法:按分组上传 + 缓冲复用 + bulk 拷贝

2.1 按"要素分组"合并成一个 VAO

这套引擎用一个可配置的"每批要素数"(比如 100 或 1000)作为分组依据,把要素 ID 对分组大小取整,同一批的要素合并进同一个顶点数组对象(VAO)。

分组大小是个权衡点:

  • 100:更新时粒度小,编辑单个要素只需重传这一组,更新快。
  • 1000:批次数减少 10 倍,glDrawElements 调用次数大降,渲染快。

“编辑场景选小分组,纯渲染场景选大分组”,这是很实用的工程取舍。

分组大小是旋钮:编辑用小分组、纯渲染用大分组。

2.2 缓冲复用:不重复申请 DirectBuffer

上传顶点数据最忌频繁申请堆外内存。这套引擎用一个可复用的最大缓冲,只有在需要更大空间时才重新分配;够用时直接复用,重置 position 后写入:

下面这段在"需要更大才重新分配 DirectBuffer,否则复用并重置写位置"之间做判断,避免反复申请堆外内存。

// 复用大缓冲,避免反复分配 DirectByteBuffer(示意)
fun uploadMesh(meshes: List<MeshData>, vbo: Int) {
val needed = stride * totalVertices(meshes)
val vbb = if (maxVbb == null || maxVbb.capacity() < needed) {
ByteBuffer.allocateDirect(needed).order(ByteOrder.nativeOrder())
} else {
maxVbb!!.position(0) // 复用,重置写位置
}
for (mesh in meshes) vbb.put(mesh.data()) // 批量写入
vbb.flip()
GLES30.glBindBuffer(GL_ARRAY_BUFFER, vbo)
GLES30.glBufferData(GL_ARRAY_BUFFER, vbb.limit(), vbb, GL_STATIC_DRAW)
}

顶点缓冲和索引缓冲各自维护一个最大缓冲,重复使用,降低内存峰值和 GC 压力。

复用"最大缓冲"比反复申请 DirectBuffer 更能压住内存峰值和 GC。

2.3 用堆内临时数组做 bulk 拷贝,减少 JNI 调用

逐顶点往 DirectByteBuffer 里 put,每次都触发一次 JNI 调用,几十万顶点下来开销惊人。更聪明的做法是:先在堆内的普通数组里拼好,然后一次性 bulk copy 到 DirectBuffer:

下面这段先在堆内 ByteArray 拼好顶点,再一次性 put 进 DirectBuffer,把几十万次 JNI 变成一次 bulk 拷贝。

// 先拼到堆内数组,再一次性拷贝(示意)
val vertexBytes = ByteArray(totalVertices * stride)
// … 填充 vertexBytes(纯 Java 内存操作,无 JNI)…
vbb.clear()
vbb.put(vertexBytes, 0, used) // 单次 bulk 拷贝
vbb.flip()

这样把"几十万次小 JNI 调用"变成"几次 bulk 拷贝",性能提升非常明显。拷完立即释放临时数组,降低内存峰值。

先堆内拼数组、再 bulk 拷贝,把几十万次小 JNI 压成几次大拷贝。

2.4 数据更新:只重传变化的那一组

分组带来的最大好处是"部分更新"。当只有某个要素变化时,只需要重传它所在的那一组,而不是整个图层:

下面这段把更新的要素按"所在分组"去重后,只重传这些分组,而不是全量重传。

// 仅重传"变化要素所在的分组"(示意)
fun uploadUpdates(updates: Set<Long>) {
val groupKeys = updates.map { it / GROUP_SIZE }.toSet()
for (key in groupKeys) uploadGroup(key) // 只重传这些分组
}

配合 CPU 侧维护的"已缓存 fid"集合,可以做到:同一个 fid 的 mesh 已在 GPU 上,就不重复上传;删除了就释放对应缓冲。更新成本被压到最小。

分组 + 已缓存集合支撑"部分更新",数据变了只重传对应组。

三、升华:这些优化思路能沉淀成什么通用原则

这套引擎把 GL 性能优化的工程经验浓缩成了几条可复用的原则:

  • 按批分组:用"分组大小"这个旋钮,在更新粒度和调用次数之间做权衡,而不是非黑即白。
  • 缓冲复用:DirectBuffer 反复申请是隐性杀手,用"最大缓冲复用"模式规避。
  • bulk 优先:一切能合并成一次拷贝/一次调用的,绝不拆成多次小操作。
  • 部分更新:能只重传变更部分的,绝不全量重传——分组为它提供了基础。

按批分组、缓冲复用、bulk 优先、部分更新,是四条可复用的 GL 性能原则。

结论

  • GL 慢的根源往往是调用次数太多,而非数据量本身,铁律是"减少调用"。
  • 按可配置的分组大小把要素合并成 VAO,编辑选小分组、渲染选大分组。
  • 复用最大 DirectBuffer,避免频繁申请堆外内存造成 GC 和峰值。
  • 先堆内拼数组、再 bulk 拷贝,把几十万次小 JNI 变成几次大拷贝。
  • 分组 + 已缓存集合支撑"部分更新",数据变了只重传对应组,更新成本最小化。

你在做移动端 OpenGL 渲染时,还踩过哪些"调用次数"或"JNI 拷贝"的坑?欢迎在评论区聊聊你的优化经验。

关键词标签:#Android #OpenGL #顶点缓冲 #VBO #批处理 #显存复用 #GLSL #渲染性能

赞(0)
未经允许不得转载:171主机测评 » Android 地图几万要素卡成幻灯片?顶点缓冲批上传与显存复用
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址