Metal 进阶:读取可绘制对象的像素

引言

Hi,大家好,我是一牛。同学们已经了解过,如何绘制三角形到Metal 的可绘制对象中。Metal 的可绘制对象其实就是一幅当前帧的纹理。同学们可能会猜想,既然我们能绘制三角形,那我们是否可以读取这幅纹理的像素,或者将该纹理保存为图片? 答案是可以的。今天我将带着大家一起学习下如何读取可绘制对象(Drawable)的像素。

关键配置

ini 复制代码
metalView.framebufferOnly = false

首先我们需要将这个属性设置成false 。这个属性的默认值是true ,表示可绘制对象的纹理只能用来渲染,而我们需要读取该纹理,所以我们需要将这个属性设置成false。值得注意的是,这样做可能会影响性能,我们在做性能优化时,需要考虑到这点。

ini 复制代码
metalView.colorPixelFormat = MTLPixelFormat.rgba8Unorm

为了方便演示,我们将可绘制对象的纹理像素格式设置成rgba8Unorm ,这代表像素有四个通道,每个通道是8个比特,并且按照 RGBA 的顺序排列。

创建渲染管线描述符

ini 复制代码
let pipelineDescriptor = MTLRenderPipelineDescriptor()
pipelineDescriptor.vertexFunction = vertextFunc
pipelineDescriptor.fragmentFunction = fragmentFunc
pipelineDescriptor.colorAttachments[0].pixelFormat = mtkView.colorPixelFormat;

在这里我们需要将颜色附件的像素格式设置成和可绘制对象的纹理像素格式一致。在 Metal 中颜色附件用于渲染管线中的渲染目标。颜色附件通常与帧缓冲区相关联,最终渲染的结果会被写入到颜色附件中,所以二者的像素格式必须一致。

渲染渐变的长方形

php 复制代码
func drawQuad(in view: MTKView, with commandBuffer: MTLCommandBuffer?) {
    guard let renderPassDesriptor = view.currentRenderPassDescriptor else {
        return
    }
    let commanderEncoder = commandBuffer?.makeRenderCommandEncoder(descriptor: renderPassDesriptor)
    commanderEncoder?.setRenderPipelineState(pipelineState)
    let vertices = [
        //top-left
        Vertext(position: [-1.0,  1.0, 1.0, 1.0], color: [1.0, 0.0, 1.0, 1.0]),
        //bottom-left
        Vertext(position: [-1.0, -1.0, 1.0, 1.0], color: [1.0, 0.0, 0.0, 1.0]),
        //bottom-right
        Vertext(position: [ 1.0, -1.0, 1.0, 1.0], color: [1.0, 1.0, 0.0, 1.0]),
        //top-left
        Vertext(position: [-1.0,  1.0, 1.0, 1.0], color: [1.0, 0.0, 1.0, 1.0]),
        //bottom-right
        Vertext(position: [ 1.0, -1.0, 1.0, 1.0], color: [1.0, 1.0, 0.0, 1.0]),
        //top-right
        Vertext(position: [ 1.0,  1.0, 1.0, 1.0], color: [0.0, 1.0, 0.0, 1.0]),
    ]
    let vertexBuffer = device.makeBuffer(bytes: vertices, length: MemoryLayout<Vertext>.stride * vertices.count)
    commanderEncoder?.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
    commanderEncoder?.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: vertices.count)
    commanderEncoder?.endEncoding()
}

在这里,我们绘制了两个三角形,并且给三角形的顶点设置了颜色,并且我们在片元着色器中直接返回了三角形顶点的颜色,Metal 会使用插值算法,将三角形内部做像素填充,实现渐变效果。

读取像素值

less 复制代码
func readPixels(commandBuffer: MTLCommandBuffer?, from texture: MTLTexture, at region: CGRect) -> MTLBuffer? {
    let sourceOrigin = MTLOrigin(x: Int(region.origin.x), y: Int(region.origin.y), z: 0)
    let sourceSize = MTLSize(width: Int(region.size.width), height: Int(region.size.height), depth: 1)
    let bytesPerPixel = 4
    let bytesPerRow = sourceSize.width * bytesPerPixel
    let bytesPerImage = sourceSize.height * bytesPerRow
    guard let readBuffer = texture.device.makeBuffer(length: bytesPerImage, options: .storageModeShared) else {
        return nil
    }
    let blitEncoder = commandBuffer?.makeBlitCommandEncoder()
    blitEncoder?.copy(from: texture, sourceSlice: 0, sourceLevel: 0, sourceOrigin: sourceOrigin, sourceSize: sourceSize, to: readBuffer, destinationOffset: 0, destinationBytesPerRow: bytesPerRow, destinationBytesPerImage: bytesPerImage)
    blitEncoder?.endEncoding()
    commandBuffer?.commit()
    commandBuffer?.waitUntilCompleted()
    return readBuffer
}

为了读取纹理的像素值,我们需要使用Blit命令编码器,用于将图像数据从一个缓冲区拷贝到另一个缓冲区。

在这里我们将输出的像素缓冲设置成统一内存(GPUCPU 都可以访问),这样Blit命令编码器可以将GPU中的图像数据拷贝到该输出缓冲中。

swift 复制代码
// 设置输出缓存为统一内存
guard let readBuffer = texture.device.makeBuffer(length: bytesPerImage, options: .storageModeShared) else {
return nil
}

之前我们将Metal 的可绘制对象的纹理像素格式设置成rgba8Unorm,所以在这里我们需要设置输出缓冲匹配该像素格式。

csharp 复制代码
// 每个像素4个字节
let bytesPerPixel = 4
// 每一行的字节数
let bytesPerRow = sourceSize.width * bytesPerPixel
// 图片的字节数
let bytesPerImage = sourceSize.height * bytesPerRow

提交命令缓冲时,我们还需要在GPU执行命令缓冲区前,阻塞当前线程,我们才能读取正确的像素。

scss 复制代码
commandBuffer?.waitUntilCompleted()

点击屏幕获取像素

由于纹理左上角坐标是 (0, 0) , 而当前视图左下角的坐标是 (0,0) , 我们需要将点击点转换到纹理坐标

ini 复制代码
let bottomUpPixelPosition = view.convertToBacking(event.locationInWindow)
let bottomDownPixelPosition = CGPoint(x: bottomUpPixelPosition.x, y: view.frame.size.height - bottomUpPixelPosition.y)

点击时我们需要先编码渲染命令,然后编码读取纹理命令,这两个操作需要在一帧内提交给GPU 。由于此时手动渲染了一帧图片,因此在MTKViewDelegate中不需要重复渲染。

php 复制代码
let commandBuffer = commandQueue.makeCommandBuffer()
// 渲染纹理
drawQuad(in: theView, with: commandBuffer)
isDrawForReadThisFrame = false
guard let readTexture = theView.currentDrawable?.texture else {
    return nil
}
// We only want to get the pixel of the click point.
let region = CGRect(x: pixelPosition.x, y: pixelPosition.y, width: 1, height: 1)
// 读取纹理
guard let pixelBuffer = readPixels(commandBuffer: commandBuffer, from: readTexture, at: region) else {
    return nil
}

将输出缓存转像素

less 复制代码
let pixelPointer = pixelBuffer.contents().bindMemory(to: UInt8.self, capacity: 4)
let r = pixelPointer[0]
let g = pixelPointer[1]
let b = pixelPointer[2]
let a = pixelPointer[3]
return Pixel(r: r, g: g, b: b, a: a)

结语

通过Blit 命令编码器,我们将帧缓冲的纹理拷贝到统一内存中,实现了读取屏幕像素的目的。

本实例已开源

相关推荐
二流小码农2 小时前
鸿蒙开发:如何更新对象数组
android·ios·harmonyos
GeniuswongAir3 小时前
苹果新规生效:即日起不再接受iOS 17 SDK编译的应用提交
ios
Mintopia6 小时前
计算机图形学进阶探索与实践
前端·javascript·计算机图形学
东坡肘子9 小时前
Chrome 会成为 OpenAI 的下一个目标?| 肘子的 Swift 周报 #081
人工智能·swiftui·swift
恋猫de小郭1 天前
腾讯 Kuikly 正式开源,了解一下这个基于 Kotlin 的全平台框架
android·前端·ios
超人强1 天前
一文搞定App启动流程、时间监测、优化措施
ios
一牛1 天前
Appkit: 菜单是如何工作的
macos·ios·objective-c
JQShan1 天前
React Native小课堂:箭头函数 vs 普通函数,为什么你的this总迷路?
javascript·react native·ios
Mintopia1 天前
JavaScript 中的计算机图形学核心知识点详解
前端·javascript·计算机图形学
画个大饼1 天前
Swift与iOS内存管理机制深度剖析
开发语言·ios·swift