You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Metal绘图应用渲染超4000笔时性能低下的优化求助

基于Metal的绘图应用渲染性能优化问题

我用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()
}

核心问题与优化建议

  1. 按纹理分组批量绘制
    当前实现合并所有笔画的点,但忽略了不同笔画可能使用不同纹理——频繁切换纹理会带来GPU状态切换开销。建议按纹理分组,同一纹理的笔画批量提交绘制,减少setFragmentTexture的调用次数。

  2. 消除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
        // 纹理相同则累积绘制范围,纹理不同则先提交上一组绘制,切换纹理后继续
    }
    
  3. 修正绘制参数错误
    第二次优化的代码中,drawPrimitives的vertexCount写为pointsAlongPath.count,实际应为总顶点数allPoints.count,这个错误会导致渲染顶点数异常,先修正此问题。

  4. 使用间接渲染
    对于大量绘制请求,间接渲染可让GPU自主管理绘制参数,减少CPU提交开销。可提前将每个Stroke的绘制参数(顶点起始位置、顶点数)缓存到Buffer中,用drawPrimitivesIndirect一次性提交所有请求,适合按纹理分组后的批量处理。

  5. 预缓存顶点Buffer
    既然cachedPointsAlongPath已缓存,可提前将每个Stroke的顶点数据创建为Metal Buffer并缓存,渲染时直接设置对应Buffer和偏移,避免每次内存拷贝,进一步降低CPU开销。注意做好Buffer生命周期管理,避免内存泄漏。

  6. 分析GPU性能瓶颈
    目前仅关注CPU占用,需用Xcode的Metal System Trace分析GPU:是否存在顶点处理瓶颈、片元着色器开销过大、纹理采样效率低等问题。若为片元着色器问题,可优化笔刷纹理采样(如使用mipmap、压缩纹理)或简化着色器逻辑。

  7. 视锥体剔除
    若视图仅显示部分内容,可只渲染当前视口内的笔画:提前计算每个Stroke的包围盒,判断是否在视口内,仅处理可见笔画,减少需渲染的顶点数。

内容的提问来源于stack exchange,提问作者Brew Master

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.10 10:05:32