Metal绘图应用渲染超4000笔时性能低下的优化求助
我用Metal开发绘图应用,和其他应用不同,所有笔画都存储为数据模型而非仅保存绘制后的位图——这样用户使用橡皮擦工具时,能单独删除某一笔画而不影响其他内容。但擦除操作后需要重新渲染剩余笔画,目前遇到严重的性能瓶颈。
数据模型为笔画数组,每笔包含触摸捕捉的CGPoint数组(红色点):
渲染时会计算并缓存路径上的点(紫色点),用同一纹理在每个紫色点上盖章。一幅图最多包含4000多笔,每笔约150个紫色点,理论上Metal完全能处理这个规模,但实际渲染表现很差。
初始渲染实现
全量渲染函数
func renderAllStrokes(strokes: [Stroke]) { renderer.updateUniFormBuffer() renderer.updateTransformBuffer() renderer.clear() for stroke in strokes { self.renderStroke(stroke: stroke) } renderer.commitCommandBufer() self.setNeedsDisplay() }
单笔画渲染函数
func renderStroke(stroke: Stroke) { // 使用路径上的缓存点,保证纹理盖章间距一致 let pointsAlongPath = stroke.cachedPointsAlongPath let commandBuffer = commandQueue?.makeCommandBuffer() let commandEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderer.renderPassDescriptor) commandEncoder?.setRenderPipelineState(pipelineState) let pointsAlongPathBuffer = sharedDevice?.makeBuffer(bytes: pointsAlongPath, length: MemoryLayout<MetalStrokePoint>.stride * pointsAlongPath.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() }
初始性能问题
pointsAlongPath已缓存无需重复计算,但渲染4000笔耗时2秒,应用无响应,CPU占用率达130%。性能分析显示:26%的CPU开销来自commandQueue?.makeCommandBuffer(),9%来自sharedDevice?.makeBuffer(...)。
我猜测频繁创建CommandBuffer是问题之一,但Apple官方称其创建成本低。另外,每笔可能使用不同纹理(应用支持10余种笔刷),不确定单CommandBuffer是否可行;考虑过实例化渲染,但不确定作用——因为已经复用了uniformBuffer、transformBuffer和stampTexture。
第一次优化:单CommandBuffer批量渲染
按建议将4000笔编码到单个CommandBuffer中:
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() }
优化后效果
现在仅需1次绘制调用,用CADisplayLink循环渲染时,FPS从2提升至12,但离60甚至120的目标仍有很大差距。4000笔需处理约100万个顶点,性能仍需进一步优化。
第二次优化:复用环形缓冲区
实现三重环形缓冲区避免每次渲染创建新Buffer,但FPS未提升,仅降低了5%的CPU占用,不清楚问题出在哪:
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) } // 复用环形缓冲区,避免创建新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) renderer.commandBuffer?.addCompletedHandler({ commandBuffer in renderer.ringBufferSemaphore?.signal() }) renderer?.commitCommandBufer() }
核心问题与优化建议
按纹理分组批量绘制
当前实现合并所有笔画的点,但忽略了不同笔画可能使用不同纹理——频繁切换纹理会带来GPU状态切换开销。建议按纹理分组,同一纹理的笔画批量提交绘制,减少setFragmentTexture的调用次数。消除CPU端数组合并开销
每次渲染将所有pointsAlongPath合并到Swift数组的操作,在100万顶点规模下是巨大的CPU开销。建议直接用memcpy将每个Stroke的缓存点拷贝到环形缓冲区,跳过Swift数组的中间步骤:var bufferOffset = 0 let totalPointCount = strokes.reduce(0) { $0 + $1.cachedPointsAlongPath.count } let ringBuffer = renderer.ringBuffers.getCurrentRingBuffer() let ringBufferPtr = ringBuffer.contents().bindMemory(to: MetalStrokePoint.self, capacity: totalPointCount) for stroke in strokes { let points = stroke.cachedPointsAlongPath memcpy(ringBufferPtr + bufferOffset, points, MemoryLayout<MetalStrokePoint>.stride * points.count) bufferOffset += points.count // 纹理相同则累积绘制范围,纹理不同则先提交上一组绘制,切换纹理后继续 }修正绘制参数错误
第二次优化的代码中,drawPrimitives的vertexCount写为pointsAlongPath.count,实际应为总顶点数allPoints.count,这个错误会导致渲染顶点数异常,先修正此问题。使用间接渲染
对于大量绘制请求,间接渲染可让GPU自主管理绘制参数,减少CPU提交开销。可提前将每个Stroke的绘制参数(顶点起始位置、顶点数)缓存到Buffer中,用drawPrimitivesIndirect一次性提交所有请求,适合按纹理分组后的批量处理。预缓存顶点Buffer
既然cachedPointsAlongPath已缓存,可提前将每个Stroke的顶点数据创建为Metal Buffer并缓存,渲染时直接设置对应Buffer和偏移,避免每次内存拷贝,进一步降低CPU开销。注意做好Buffer生命周期管理,避免内存泄漏。分析GPU性能瓶颈
目前仅关注CPU占用,需用Xcode的Metal System Trace分析GPU:是否存在顶点处理瓶颈、片元着色器开销过大、纹理采样效率低等问题。若为片元着色器问题,可优化笔刷纹理采样(如使用mipmap、压缩纹理)或简化着色器逻辑。视锥体剔除
若视图仅显示部分内容,可只渲染当前视口内的笔画:提前计算每个Stroke的包围盒,判断是否在视口内,仅处理可见笔画,减少需渲染的顶点数。
内容的提问来源于stack exchange,提问作者Brew Master

