Metal 框架之从可绘制纹理中读取像素数据

1,491 阅读6分钟

「这是我参与11月更文挑战的第18天,活动详情查看:2021最后一次更文挑战

概述

Metal 优化了纹理以供 GPU 快速访问,但不允许直接从 CPU 访问纹理的内容。当 App 需要更改或读取纹理的内容时,需要 Metal 在纹理和可访问的 CPU 内存(系统内存或使用共享存储分配的 Metal 缓冲区)之间复制数据。本文示例配置了可绘制的纹理,该纹理具备可读取权限,并将渲染像素数据从这些纹理复制到 Metal 缓冲区。

运行示例,然后点击或单击单个点以读取存储在该点的像素数据。或者,拖出一个矩形以捕获屏幕上某个区域的像素数据。本示例将你的选择转换为可绘制纹理坐标系中的矩形,然后将图像渲染到纹理。最后,将所选矩形中的像素数据复制到缓冲区中,以供后续进一步处理。

配置可绘制纹理的读取权限

默认情况下,MetalKit 视图创建仅用于渲染的可绘制纹理,其他 Metal 命令无法访问该纹理。下面的代码创建一个视图,开启了纹理的读取访问权限,每当用户选择视图的一部分时,示例都需要获取纹理,因此代码将视图的 Metal 层配置为无限期地等待新的可绘制对象。


_view.framebufferOnly = NO;

((CAMetalLayer*)_view.layer).allowsNextDrawableTimeout = NO;

_view.colorPixelFormat = MTLPixelFormatBGRA8Unorm;

配置可绘制纹理的读取访问权限意味着 Metal 可能不会做一些优化,因此仅在必要时更改可绘制配置。同样的,对性能敏感的 App 不要将视图配置成无限期地等待。

确定要复制的像素

使用 AAPLViewController 类管理用户交互,当用户与视图交互时,AppKit 和 UIKit 发送带着位置的事件。 为了确定从 Metal 可绘制纹理中复制哪些像素,App 将这些视图坐标转换为 Metal 的纹理坐标系。

由于图形坐标系和 API 的差异,在视图坐标和纹理坐标之间转换的代码因平台而异。

在 macOS 中,调用视图上的 pointToBacking: 方法将位置转换为后备存储中的像素位置,然后应用坐标变换来调整原点和 y 轴。


CGPoint bottomUpPixelPosition = [_view convertPointToBacking:event.locationInWindow];

CGPoint topDownPixelPosition = CGPointMake(bottomUpPixelPosition.x,
_view.drawableSize.height - bottomUpPixelPosition.y);

在 iOS 中,App 读取视图的 contentScaleFactor 并将缩放变换应用于视图坐标。 iOS 视图和 Metal 纹理使用相同的坐标约定,因此不需要移动原点或更改 y 轴方向。


- (CGPoint)pointToBacking:(CGPoint)point

{

    CGFloat scale = _view.contentScaleFactor;

    CGPoint pixel;

    pixel.x = point.x * scale;

    pixel.y = point.y * scale;


    // Round the pixel values down to put them on a well-defined grid.

    pixel.x = (int64_t)pixel.x;

    pixel.y = (int64_t)pixel.y;


    // Add .5 to move to the center of the pixel.

    pixel.x += 0.5f;

    pixel.y += 0.5f;

    return pixel;

}

渲染像素数据

当用户在视图中选择一个矩形时,视图控制器调用 renderAndReadPixelsFromView:withRegion 方法来渲染可绘制的内容并将它们复制到 Metal 缓冲区中。

创建一个新的命令缓冲区并调用一个实用方法来编码渲染通道。 


id<MTLCommandBuffer> commandBuffer = [_commandQueue commandBuffer];

// Encode a render pass to render the image to the drawable texture.

[self drawScene:view withCommandBuffer:commandBuffer];

对渲染通道进行编码后,调用另一种方法来编码命令以复制渲染纹理的一部分。 本示例在呈现可绘制纹理之前,对复制像素数据的命令进行编码,因为系统在呈现后会丢弃纹理的内容。


id<MTLTexture> readTexture = view.currentDrawable.texture;

MTLOrigin readOrigin = MTLOriginMake(region.origin.x, region.origin.y, 0);

MTLSize readSize = MTLSizeMake(region.size.width, region.size.height, 1);

const id<MTLBuffer> pixelBuffer = [self readPixelsWithCommandBuffer:commandBuffer

                                                        fromTexture:readTexture

                                                           atOrigin:readOrigin

                                                           withSize:readSize];

将像素数据复制到缓冲区

渲染器的 readPixelsWithCommandBuffer:fromTexture:atOrigin:withSize: 方法对复制纹理的命令进行编码。 由于示例将相同的命令缓冲区传递给此方法,因此 Metal 在渲染过程之后对这些新命令进行编码。 Metal 自动管理目标纹理的依赖关系,并确保在复制纹理数据之前完成渲染。

首先,该方法分配一个 Metal 缓冲区来保存像素数据,通过将一个像素的大小(以字节为单位)乘以区域的宽度和高度来计算缓冲区的大小。 同样,计算每行的字节数,稍后复制数据时需要这些字节数(该示例不会在行的末尾添加任何填充)。 然后,调用 Metal 设备对象来创建新的 Metal 缓冲区,指定共享存储模式,以便 App 可以在之后读取缓冲区的内容。


NSUInteger bytesPerPixel = sizeofPixelFormat(texture.pixelFormat);

NSUInteger bytesPerRow   = size.width * bytesPerPixel;

NSUInteger bytesPerImage = size.height * bytesPerRow;

_readBuffer = [texture.device newBufferWithLength:bytesPerImage 
                                        options:MTLResourceStorageModeShared];

接下来,创建一个 MTLBlitCommandEncoder 对象来提供命令,在 Metal 资源之间复制数据,用数据填充资源,以及执行其他类似的资源相关的任务(这些任务不直接涉及计算或渲染)。该示例对 blit 命令进行编码以将纹理数据复制到新缓冲区的开头, 然后关闭 blit 通道。


id <MTLBlitCommandEncoder> blitEncoder = [commandBuffer blitCommandEncoder];

[blitEncoder copyFromTexture:texture

                 sourceSlice:0

                 sourceLevel:0

                sourceOrigin:origin

                  sourceSize:size

                    toBuffer:_readBuffer

           destinationOffset:0

      destinationBytesPerRow:bytesPerRow

    destinationBytesPerImage:bytesPerImage];

[blitEncoder endEncoding];

最后,提交命令缓冲区,并调用 waitUntilCompleted 等待 GPU 完成渲染和 blit 命令的执行。 在此调用将控制权返回给该方法后,缓冲区将包含请求的像素数据。 在实时 App 中,同步命令会不必要地减少 CPU 和 GPU 之间的并行度。


[commandBuffer commit];

// The app must wait for the GPU to complete the blit pass before it can

// read data from _readBuffer.

[commandBuffer waitUntilCompleted];

从缓冲区读取像素

调用缓冲区的 contents() 方法可以获取指向像素数据的指针。


AAPLPixelBGRA8Unorm *pixels = (AAPLPixelBGRA8Unorm *)pixelBuffer.contents;

本文示例将缓冲区的数据复制到 NSData 对象中,并将其传递给另一个方法来初始化 AAPLImage 对象。 有关 AAPLImage 的更多信息,请参阅 《 Metal 框架之创建纹理及纹理采样 》


// Create an `NSData` object and initialize it with the pixel data.

// Use the CPU to copy the pixel data from the `pixelBuffer.contents`

// pointer to `data`.

NSData *data = [[NSData alloc] initWithBytes:pixels length:pixelBuffer.length];

// Create a new image from the pixel data.

AAPLImage *image = [[AAPLImage alloc] initWithBGRA8UnormData:data

                                                       width:readSize.width

                                                      height:readSize.height];

渲染器将此图像对象返回给视图控制器以进行进一步处理。 视图控制器的行为因操作系统而异。 在 MacOS 中,示例将图像写入文件 ~/Desktop/ReadPixelsImage.tga,而在 iOS 中,示例将图像添加到照片库中。 

总结

本文介绍了如何从渲染后的纹理获取像素数据,即 cpu 如何 获取 GPU 渲染的结果。首先需要开启纹理的可读取权限,然后渲染纹理,将纹理拷贝到一个新的缓冲区中,最后从新的缓冲区获取数据。

本文示例代码下载