在大纹理上渲染小纹理时,Metal 比 OpenGL 慢得多

Metal much slower compared to OpenGL while rendering small textures on a large texture

我正在尝试将我的项目从 OpenGL 迁移到 iOS 上的 Metal。但我似乎遇到了性能瓶颈。任务很简单...

我的纹理很大(超过 3000x3000 像素)。我需要在每个 touchesMoved 事件上绘制几个(几百个)小纹理(比如 124x124)。这是在启用特定混合功能的同时。它基本上就像一个画笔。然后显示大纹理。任务大致就是这样。

在 OpenGL 上 运行 非常快。我得到大约 60fps。当我将相同的代码移植到 Metal 时,我只能设法获得 15fps。

我已经创建了两个最低限度的示例项目来演示该问题。这是项目(OpenGL 和 Metal)...

https://drive.google.com/file/d/12MPt1nMzE2UL_s4oXEUoTCXYiTz42r4b/view?usp=sharing

这就是我在 OpenGL 中所做的大致...

    - (void) renderBrush:(GLuint)brush on:(GLuint)fbo ofSize:(CGSize)size at:(CGPoint)point {
    GLfloat brushCoordinates[] = {
        0.0f, 0.0f,
        1.0f, 0.0f,
        0.0f,  1.0f,
        1.0f,  1.0f,
    };

    GLfloat imageVertices[] = {
        -1.0f, -1.0f,
        1.0f, -1.0f,
        -1.0f,  1.0f,
        1.0f,  1.0f,
    };

    int brushSize = 124;

    CGRect rect = CGRectMake(point.x - brushSize/2, point.y - brushSize/2, brushSize, brushSize);

    rect.origin.x /= size.width;
    rect.origin.y /= size.height;
    rect.size.width /= size.width;
    rect.size.height /= size.height;

    [self convertImageVertices:imageVertices toProjectionRect:rect onImageOfSize:size];

    int currentFBO;
    glGetIntegerv(GL_FRAMEBUFFER_BINDING, &currentFBO);

    [_Program use];

    glBindFramebuffer(GL_FRAMEBUFFER, fbo);
    glViewport(0, 0, (int)size.width, (int)size.height);

    glActiveTexture(GL_TEXTURE2);
    glBindTexture(GL_TEXTURE_2D, brush);
    glUniform1i(brushTextureLocation, 2);

    glVertexAttribPointer(positionLocation, 2, GL_FLOAT, 0, 0, imageVertices);
    glVertexAttribPointer(brushCoordinateLocation, 2, GL_FLOAT, 0, 0, brushCoordinates);

    glEnable(GL_BLEND);
    glBlendEquation(GL_FUNC_ADD);
    glBlendFuncSeparate(GL_ONE, GL_ZERO, GL_ONE, GL_ONE);

    glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);

    glDisable(GL_BLEND);

    glActiveTexture(GL_TEXTURE2);
    glBindTexture(GL_TEXTURE_2D, 0);

    glBindFramebuffer(GL_FRAMEBUFFER, currentFBO);
}

我运行 这段代码在每个触摸事件中循环(大约 200-500)。它 运行 非常快。

这就是我将代码移植到 Metal 的方式...

- (void) renderBrush:(id<MTLTexture>)brush onTarget:(id<MTLTexture>)target at:(CGPoint)point withCommandBuffer:(id<MTLCommandBuffer>)commandBuffer {

int brushSize = 124;

CGRect rect = CGRectMake(point.x - brushSize/2, point.y - brushSize/2, brushSize, brushSize);

rect.origin.x /= target.width;
rect.origin.y /= target.height;
rect.size.width /= target.width;
rect.size.height /= target.height;

Float32 imageVertices[8];
// Calculate the vertices (basically the rectangle that we need to draw) on the target texture that we are going to draw
// We are not drawing on the entire target texture, only on a square around the point
[self composeImageVertices:imageVertices toProjectionRect:rect onImageOfSize:CGSizeMake(target.width, target.height)];

// We use different one vertexBuffer per pass. This is because this is run on a loop and the subsequent calls will overwrite
// The values. Other buffers also get overwritten but that is ok for now, we only need to demonstrate the performance.
id<MTLBuffer> vertexBuffer = [_vertexArray lastObject];

memcpy([vertexBuffer contents], imageVertices, 8 * sizeof(Float32));

id<MTLRenderCommandEncoder> commandEncoder = [commandBuffer renderCommandEncoderWithDescriptor:mRenderPassDescriptor];
commandEncoder.label = @"DrawCE";

[commandEncoder setRenderPipelineState:mPipelineState];

[commandEncoder setVertexBuffer:vertexBuffer offset:0 atIndex:0];
[commandEncoder setVertexBuffer:mBrushTextureBuffer offset:0 atIndex:1];

[commandEncoder setFragmentTexture:brush atIndex:0];
[commandEncoder setFragmentSamplerState:mSampleState atIndex:0];

[commandEncoder drawPrimitives:MTLPrimitiveTypeTriangleStrip vertexStart:0 vertexCount:4];
[commandEncoder endEncoding];

}

然后 运行 这段代码在一个循环中,每个触摸事件都有一个 MTLCommandBuffer,例如...

    id<MTLCommandBuffer> commandBuffer = [MetalContext.defaultContext.commandQueue commandBuffer];
commandBuffer.label = @"DrawCB";

dispatch_semaphore_wait(_inFlightSemaphore, DISPATCH_TIME_FOREVER);

mRenderPassDescriptor.colorAttachments[0].texture = target;

__block dispatch_semaphore_t block_sema = _inFlightSemaphore;
[commandBuffer addCompletedHandler:^(id<MTLCommandBuffer> buffer) {
    dispatch_semaphore_signal(block_sema);
}];

_vertexArray = [[NSMutableArray alloc] init];
for (int i = 0; i < strokes; i++) {
    id<MTLBuffer> vertexBuffer = [MetalContext.defaultContext.device newBufferWithLength:8 * sizeof(Float32) options:0];
    [_vertexArray addObject:vertexBuffer];

    id<MTLTexture> brush = [_brushes objectAtIndex:rand()%_brushes.count];
    [self renderBrush:brush onTarget:target at:CGPointMake(x, y) withCommandBuffer:commandBuffer];
    x += deltaX;
    y += deltaY;
}

[commandBuffer commit];

在我附加的示例代码中,我用定时器循环替换了触摸事件以保持简单。

在 iPhone 7 Plus 上,我使用 OpenGL 获得 60fps,使用 Metal 获得 15fps。可能是我在这里做错了什么?

删除所有冗余:

  • 不要在渲染时创建缓冲区。在初始化期间分配足够的缓冲区。
  • 不要为每个四边形创建命令编码器。
  • 为每个四边形使用一个具有不同(正确对齐)偏移的大顶点缓冲区。使用 -setVertexBufferOffset:atIndex: 根据需要仅设置偏移量,而不更改缓冲区。
  • composeImageVertices:... 可以通过适当的转换直接写入顶点缓冲区,避免 memcpy.
  • 根据 composeImageVertices:... 实际执行的操作,如果 deltaXdeltaY 是常量,您可以设置顶点缓冲区一次,永远。顶点着色器可以根据需要变换顶点。您可以将适当的数据作为制服传递(目标点和渲染目标大小,甚至是变换矩阵)。
  • 假设它们每次都相同,请不要每次都设置 mPipelineStatemBrushTextureBuffermSampleState
  • 如果任何四边形共享相同的画笔纹理,将它们组合在一起并执行一个绘制命令来绘制它们。这可能需要切换到三角形图元而不是三角形条带图元。但是,如果您进行索引绘制,则可以使用原语重启哨兵在一个绘制命令中绘制多个三角形带。
  • 如果计数不超过允许的纹理数量 (31),您甚至可以在一个绘制命令中执行多个画笔。将所有画笔纹理传递给片段着色器。它可以将它们作为纹理数组接收。顶点数据将包括画笔索引,顶点着色器将向前传递该数据,片段着色器将使用它来查找要从数组中采样的纹理。
  • 您可以使用实例化绘图在一个命令中绘制所有内容。绘制单个四边形的 stroke 个实例。在顶点着色器中,根据实例 ID 变换位置。您必须将 deltaXdeltaY 作为统一数据传递。画笔索引也可以在传入的单个缓冲区中,着色器可以通过实例 ID 在其中查找画笔索引。
  • 您是否考虑过使用点基元而不是四边形?这将减少顶点的数量并提供可用于优化光栅化的 Metal 信息。