Metal绘图应用中环形缓冲未提升FPS的问题排查
基于Metal的绘图应用渲染性能优化问题
我正在开发一款基于Metal的绘图应用,和其他绘图应用不同,这款应用将所有笔画存储为数据模型而非仅保存绘制后的位图,以此支持用户用橡皮擦工具移除单一笔画而不影响其他笔画。但擦除笔画时需要重新渲染剩余笔画,因此渲染函数需尽可能高效,目前遇到了严重的性能问题:
用CADisplayLink循环调用初始渲染函数,渲染4000笔画时,缓冲区长度达到4000000,应用FPS仅为12,响应性极差。
初始渲染实现
func renderStroke(strokes: [Stroke]) { let commandBuffer = commandQueue?.makeCommandBuffer() let commandEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderer.renderPassDescriptor) commandEncoder?.setRenderPipelineState(pipelineState) var allPoints = [MetalStrokePoint]() for stroke in strokes { let pointsAlongPath = stroke.cachedPointsAlongPath allPoints.append(contentsOf: pointsAlongPath) } let pointsAlongPathBuffer = sharedDevice?.makeBuffer(bytes: allPoints, length: MemoryLayout<MetalStrokePoint>.stride * allPoints.count, options: .cpuCacheModeWriteCombined) if let pointsAlongPathBuffer { commandEncoder?.setVertexBuffer(pointsAlongPathBuffer, offset: 0, index: 0) commandEncoder?.setVertexBuffer(renderer.uniformBuffer, offset: 0, index: 1) commandEncoder?.setVertexBuffer(renderer.transformBuffer, offset: 0, index: 2) commandEncoder?.setFragmentTexture(self.stampTexture, index: 0) commandEncoder?.drawPrimitives(type: .point, vertexStart: 0, vertexCount: pointsAlongPath.count) } renderer?.commitCommandBufer() }
环形缓冲优化后的实现
改用环形缓冲复用缓冲区后,CPU占用率降低了5%,但FPS并未提升,优化后的代码如下:
func renderStroke(strokes: [Stroke]) { // Wait for a free ring buffer renderer?.ringBufferSemaphore?.wait() let commandBuffer = commandQueue?.makeCommandBuffer() let commandEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderer.renderPassDescriptor) commandEncoder?.setRenderPipelineState(pipelineState) var allPoints = [MetalStrokePoint]() for stroke in strokes { let pointsAlongPath = stroke.cachedPointsAlongPath allPoints.append(contentsOf: pointsAlongPath) } // Reuse ring buffer to avoid creating new buffer let ringBuffer = renderer.ringBuffers.getCurrentRingBuffer() let ringBufferContents = ringBuffer.contents().bindMemory(to: MetalStrokePoint.self, capacity: allPoints.count) for (pointIndex, point) in allPoints.enumerated() { ringBufferContents[pointIndex] = point } commandEncoder?.setVertexBuffer(ringBuffer, offset: 0, index: 0) commandEncoder?.setVertexBuffer(renderer.uniformBuffer, offset: 0, index: 1) commandEncoder?.setVertexBuffer(renderer.transformBuffer, offset: 0, index: 2) commandEncoder?.setFragmentTexture(self.stampTexture, index: 0) commandEncoder?.drawPrimitives(type: .point, vertexStart: 0, vertexCount: pointsAlongPath.count) // Signal to free current ring buffer renderer.commandBuffer?.addCompletedHandler({ commandBuffer in renderer.ringBufferSemaphore?.signal() }) renderer?.commitCommandBufer() }
请问是我实现有误,还是环形缓冲在此场景下无法帮助提升FPS?
问题分析与优化建议
首先,环形缓冲的作用是减少CPU端频繁创建缓冲区的内存开销,但你的核心性能瓶颈不在内存分配,而在数据拷贝和GPU绘制压力,同时代码里存在明显错误:
1. 实现中的错误修正
- 绘制点数错误:优化后的代码里
drawPrimitives的vertexCount用了pointsAlongPath.count(单条笔画的点数),应该改为allPoints.count,否则实际绘制的点数远低于需求,这会导致渲染异常,必须先修正。 - 信号量释放对象错误:
addCompletedHandler应该绑定当前创建的commandBuffer,而非renderer.commandBuffer,否则信号量无法正确释放,可能导致后续渲染阻塞。修正后代码应为:
commandBuffer?.addCompletedHandler { _ in renderer.ringBufferSemaphore?.signal() }
2. 环形缓冲无法提升FPS的原因
环形缓冲解决的是CPU侧内存分配的开销,但你的主要性能瓶颈在两处:
- CPU端的数据拷贝开销:每次渲染都要把所有笔画的点拼接成
allPoints数组,再逐点拷贝到环形缓冲,400万点的拷贝是巨大的CPU消耗。 - GPU端的绘制压力:一次性绘制400万点,GPU的顶点处理和片元填充压力极大,这才是FPS无法提升的核心原因。
3. 针对性优化方案
- 避免CPU端数组拼接:不要将所有点合并到一个数组,而是为每条笔画提前分配Metal顶点缓冲区(将
cachedPointsAlongPath直接存储为MTLBuffer),渲染时循环调用drawPrimitives绘制单条笔画,彻底消除CPU端的大规模内存拷贝。 - 启用实例化绘制:如果所有笔画的渲染逻辑一致,将笔画的公共属性(颜色、宽度等)存入实例缓冲区,通过实例化绘制减少Draw Call数量,降低CPU命令提交开销。
- 离屏渲染缓存静态内容:对于不会被修改的笔画,渲染到离屏纹理(
MTLTexture),后续只需绘制该纹理;只有当用户擦除笔画时,再重新生成离屏纹理,避免重复渲染所有笔画。 - 减少顶点数量:检查
cachedPointsAlongPath的采样密度,根据屏幕分辨率和笔画长度做优化,剔除不必要的顶点。 - 优化渲染管线:确保渲染管线状态(
pipelineState)启用了提前深度剔除等优化,简化片元着色器逻辑,减少GPU计算压力。
内容的提问来源于stack exchange,提问作者Brew Master
相关产品推荐
相关产品推荐

