Metal与图形渲染入门篇:绘制图片

零. 前言

OpenGL,一个被广大语言运用的库,在iOS12后,被苹果打上了Deprecated的标签,如果现在的工程还引用着这个库,则会被不胜其烦地提示:该库已过期。

把OpenGL踢出苹果渲染舞台的库,叫作Metal,官方也声明已经把底层渲染支持替换为Metal,并鼓励开发者使用Metal渲染,以取代工程中的OpenGL。虽然这个库只有苹果所用,但他的易读性高、维护性强、Debug能力好、性能棒,足以成为苹果推荐该库的理由,苹果特地在WWDC19介绍了Metal,并给出了如何将OpenGL迁移到Metal的指引,详情看这个视频:Bringing OpenGL Apps to Metal

作为一个对图形渲染一窍不通的小白,突然接到一个Metal相关的需求,一开始过于急躁,像个无头苍蝇一样乱撞,但迟迟找不到通往Metal开发大门的入口,索性静下心来,好好从图形渲染开始理解,慢慢入门,强迫自己书写Demo更新博客,慢工出细活地成长,也把自己理解的心路历程与还没入门的同学分享,希望能一举两得。

这是我对Metal的第一篇文章,目前的状态是对Metal也是刚刚入门,希望自己能通过书写博客更好地成长,一步一个脚印,以完成自己的目标。第一篇文章主要讲解一下这些天来摸爬滚打搜集的一些资料和自己的一些入门级见解,如果有不对的地方欢迎指出探讨。

一. 图形渲染

工欲善其事必先利其器,如果对图形学没有一点入门理解,还是好好先看一看图形渲染的步骤,最好了解一下OpenGL的工作原理,不要因为OpenGL在苹果被废弃掉了就对其嗤之以鼻,因为这个库在苹果以外的很多地方还是被广泛应用到的,学会了图形渲染,对Metal的理解会有很大帮助。该篇章取自Learn OpenGL中文文档

1. 基本原理概括

在OpenGL中,任何事物都在3D空间中,而屏幕和窗口却是2D像素数组,这导致OpenGL的大部分工作都是关于把3D坐标转变为适应你屏幕的2D像素。3D坐标转为2D坐标的处理过程是由OpenGL的图形渲染管线(Graphics Pipeline,大多译为管线,实际上指的是一堆原始图形数据途经一个输送管道,期间经过各种变化处理最终出现在屏幕的过程)管理的。图形渲染管线可以被划分为两个主要部分:第一部分把你的3D坐标转换为2D坐标,第二部分是把2D坐标转变为实际的有颜色的像素。

图形渲染管线接受一组3D坐标,然后把它们转变为你屏幕上的有色2D像素输出。图形渲染管线可以被划分为几个阶段,每个阶段将会把前一个阶段的输出作为输入。所有这些阶段都是高度专门化的(它们都有一个特定的函数),并且很容易并行执行。正是由于它们具有并行执行的特性,当今大多数显卡都有成千上万的小处理核心,它们在GPU上为每一个(渲染管线)阶段运行各自的小程序,从而在图形渲染管线中快速处理你的数据。这些小程序叫做着色器(Shader)。

以下是图形渲染管线的每个阶段的抽象展示,也是渲染图片的一个重要步骤,相当于给一幅画勾勒出线条,再上色,三维混合(如有必要),以达到我们想要的图画效果。

2. 图形渲染的根基——三角形与像素点

在图形渲染中,有个非常非常非常重要的概念——三角形,可以这样说,如果呈现在屏幕上的图像是一座美丽的布达拉宫,那么三角形就是里面的一座地基、一根根柱子。

而你所看到的前三个步骤,就是从几个点,以三角形的方式勾勒出了整个线条。而第四个步骤则把线条做成一格一格的像素点。

  • 顶点着色器:该阶段的输入是顶点数据(Vertex Data) 数据,比如以数组的形式传递 3 个 3D 坐标用来表示一个三角形。顶点数据是一系列顶点的集合。顶点着色器主要的目的是把 3D 坐标转为另一种 3D 坐标,同时顶点着色器可以对顶点属性进行一些基本处理。

  • 形状(图元)装配:该阶段将顶点着色器输出的所有顶点作为输入,并将所有的点装配成指定图元的形状。图中则是一个三角形。图元(Primitive) 用于表示如何渲染顶点数据,如:点、线、三角形。

  • 几何着色器:该阶段把图元形式的一系列顶点的集合作为输入,它可以通过产生新顶点构造出新的(或是其它的)图元来生成其他形状。例子中,它生成了另一个三角形。(其实个人觉得这里应该加多个顶点才对,不然好像有点让人误解多出来的那条线是怎么来的)

  • 光栅化阶段(Rasterization Stage):根据几何着色器的输出,把图元映射为最终屏幕上相应的像素,生成供片段着色器(Fragment Shader)使用的片段(Fragment)。

3. 纹理、采样与着色

到光栅化这一步,我们已经可以获取到未被上色的像素了,一个图像有了初步的一些轮廓,那么他是怎么被上色,甚至被组合形成一个三维图案的呢?片段着色器就是上色的重要一环了。

  • 片段着色器的主要目的是计算一个像素的最终颜色,这也是所有OpenGL高级效果产生的地方。通常,片段着色器包含3D场景的数据(比如光照、阴影、光的颜色等等),这些数据可以被用来计算最终像素的颜色。

那么,他的颜色从哪里来呢?程序员可以根据自己想要的颜色进行上色,即直接在片段着色器写死颜色的rgba值,比如生成一个橘色的三角形:

那如果我们想读取一张图片渲染到上面去呢?像下面一样,把罗伊斯的照片贴到屏幕上去。

这时候需要引入一个同样重要的概念:纹理。

  • 纹理是一个2D图片(甚至也有1D和3D的纹理),它可以用来添加物体的细节;你可以想象纹理是一张绘有砖块的纸,无缝折叠贴合到你的3D的房子上,这样你的房子看起来就像有砖墙外表了。因为我们可以在一张图片上插入非常多的细节,这样就可以让物体非常精细而不用指定额外的顶点。

上面的概念可能有点笼统,在渲染的知识里面,你需要暂时先将一张图片看成一个一个像素点,采样器(sampler)将图片上的像素点一一采样,再映射到已经光栅化的像素点中,使其上色,最终得到一个个上色后的像素点。后文会着重介绍怎么采样纹理和给光栅化像素上色。

最后,如果涉及到3D渲染(本文暂不涉及),该阶段会检测片段的对应的深度值(z 坐标),判断这个像素位于其它物体的前面还是后面,决定是否应该丢弃。此外,该阶段还会检查 alpha 值( alpha 值定义了一个物体的透明度),从而对物体进行混合。因此,即使在片段着色器中计算出来了一个像素输出的颜色,在渲染多个三角形的时候最后的像素颜色也可能完全不同。

4. 重点——顶点着色器与片段着色器

前面主要给大家介绍了从0到1的渲染过程,那么本文则会着重介绍一下MSL(Metal Shader Language) 给我们提供的接口,也就是说,我们只需要着手这两个着色器的开发,其他步骤无需我们动手。

在基于Metal介绍这两个着色器之前,请大家再着重复习一下几个重要的概念:

  • 像素:一个图像由许多许多像素组成。

  • 顶点着色器:将原图像的3D坐标转换成适应屏幕的3D坐标,同时建立需要绘制的顶点坐标 与 需要采样的纹理坐标的映射关系。在开发中,我们需要预先设好顶点坐标与纹理坐标的映射,供系统内部光栅化处理,最后传到片段着色器中。

  • 纹理:用于被采样器采样,给片段着色器上色的图像。在开发中,我们需要读取图像的字节,调用接口生成纹理。

  • 片段着色器:基于顶点着色器的输出、纹理的采样结果,输出一个个着色后的像素,这些像素组成了一整个图像。在开发中,我们需要根据顶点着色器输出(光栅化处理后)的数据、纹理数据,对纹理进行采样,并输出该光栅化像素对应的rgba。(多个像素即为一张图片)

接下来将会介绍Metal如何运用上面几个概念,在屏幕上渲染出一张图片出来,如果读到后面有疑惑,不妨回头再看看这几个概念和他们的职能。

二. Why Metal?

在进行Metal开发之前,需要思考一个问题,为什么要用Metal进行开发,这句疑问代表着两个含义:第一,Metal在苹果开发中承担着什么样的角色;第二,为什么是Metal而不是OpenGL,下面会对这两个疑问进行解答。

1. Metal在苹果开发中承担着什么样的角色

理解Metal担任的角色之前,需要先了解一下CPU、GPU和显示器的概念:


手机包含两个不同的处理单元,CPU 和 GPU。CPU 是个多面手,并且不得不处理所有的事情,而 GPU 则可以集中来处理好一件事情,就是并行地做浮点运算。事实上,图像处理和渲染就是在将要渲染到窗口上的像素上做许许多多的浮点运算。通过有效的利用 GPU,可以成百倍甚至上千倍地提高手机上的图像渲染能力。下面的流程图显示了一个图像渲染到屏幕的流程。

通过流程图我们可以看到,在我们日常的渲染中,OpenGL/Metal已经默默地替我们承担了很多渲染的操作,如果感兴趣可以在iOS 图像渲染原理看看这些图像是怎么一步步渲染下去的。

CPU、GPU、显示器的工作方式
渲染的流水线图

总的来说,Metal担任的就是CPU和GPU交互的一个桥梁,他负责一个管理图形渲染的队列,在屏幕刷新一帧的时候,将队列的内容提交给GPU,以及时地渲染到屏幕上。

即:CPU => Metal => GPU => 显示器

2. 为什么是Metal而不是OpenGL

对于有着超过25年历史的 OpenGL 技术本身,随着现代图形技术的发展,遇到了一些问题:

  • 现代 GPU 的渲染管线已经发生变化。
  • 不支持多线程操作。
  • 不支持异步处理。
  • 较为复杂的开发语言。

随着图形学的发展,OpenGL 本身设计上存在的问题已经影响了 GPU 真正性能的发挥,因此 Apple 设计了 Metal。

为了解决这些问题,Metal 诞生了。
它为现代 GPU 设计,并面向 OpenGL 开发者。它拥有:

  • 更高效的 GPU 交互,更低的 CPU 负荷。
  • 支持多线程操作,以及线程间资源共享能力。
  • 支持资源和同步的控制。
  • 语言更符合开发者的开发习惯。
  • 可逐帧调试。

Metal 简化了 CPU 参与渲染的步骤,尽可能地让 GPU 去控制资源。与此同时,拥有更现代的设计,使操作处于可控,结果可预测的状态。在优化设计的同时,它仍然是一个直接访问硬件的框架。与 OpenGL 相比,它更加接近于 GPU,以获得更好的性能。

Metal早在2014年就已经被苹果推出,并在WWDC2018宣称OpenGL ES 将于 iOS 12 弃用。当在真机上调试 OpenGL 程序时,控制台会打印出启用 Metal 的日志。根据这一点可以猜测,Apple 已经实现了一套机制将 OpenGL 命令无缝桥接到 Metal 上,由 Metal 担任真正于硬件交互的工作。而OpenGL未来会不会被永久抛弃,我们不得而知。

三. How Metal?

好了,铺垫了这么多理论知识,下面应该开始手动实操了,我们今天的目的,是用Metal语言,将一张图片绘制到屏幕上去:

1. Metal API

Metal的简要流程图如下:

  • 命令缓存区(Command Buffer)是从命令队列(Command Queue)创建的
  • 命令编码器(Command Encoder)将命令编码到命令缓存区中
  • 提交命令缓存区并将其发送到GPU
  • GPU执行命令并将结果呈现为可绘制

那么,我们要实现一个Metal图像的绘制,需要用到哪些API呢?

(1) MTKView与MTLDevice

在MetalKit中提供了一个视图类MTKView,类似于GLKit中GLKView,它是NSView(macOS中的视图类)或者UIView(iOS、tvOS中的视图类)的子类。用于处理metal绘制并显示到屏幕过程中的细节。

MTLDevice代表GPU设备,提供创建缓存、纹理等的接口,在初始化时候需要赋给MTKView

// 初始化MTKView
self.mtkView = [[MTKView alloc] init];
self.mtkView.delegate = self;
self.device = self.mtkView.device = MTLCreateSystemDefaultDevice();
self.mtkView.frame = self.view.bounds;
[self.view addSubview:self.mtkView];

MTKView的Delegate是MTKViewDelegate,我们必须实现这个协议的方法:

- (void)mtkView:(nonnull MTKView *)view drawableSizeWillChange:(CGSize)size {
    // MTKView的大小改变
    self.viewportSize = (vector_uint2){size.width, size.height};
}

- (void)drawInMTKView:(MTKView *)view {
    // 用于向着色器传递数据
...具体实现
}

(2) MTLCommandQueue

在获取了GPU后,还需要一个渲染队列,即命令队列Command Queue类型是MTLCommandQueue,该队列是与GPU交互的第一个对象,队列中存储的是将要渲染的命令MTLCommandBuffer

队列的获取需要通过MTLDevice对象获取,且每个命令队列的生命周期很长,因此commandQueue可以重复使用,而不是频繁创建和销毁。

_commandQueue = [_device newCommandQueue];

(3) MTLRenderPipelineState

渲染管道状态 Render Pipeline State是一个协议,定义了图形渲染管道的状态,包括放在.metal文件的顶点和片段函数。

(4) MTLTexture

纹理 MTLTexture表示一个图片数据的纹理,关于纹理前面的介绍已经很多了,可以往前回顾一下。我们可以根据纹理描述器 MTLTextureDescriptor来生成MTLTexture

(5) MTLBuffer

代表一个我们自定义的数据存储资源对象,在本章中,用于存储顶点与纹理坐标数据,通过MTLDevice获取。

(6) MTLCommandBuffer

命令缓存区 Command Buffer主要是用于存储编码的命令,其生命周期是知道缓存区被提交到GPU执行为止,单个的命令缓存区可以包含不同的编码命令,主要取决于用于构建它的编码器的类型和数量。

命令缓存区的创建可以通过调用MTLCommandQueuecommandBuffer方法。且command buffer对象的提交只能提交至创建它的MTLCommandQueue对象中

commandBuffer在未提交命令缓存区之前,是不会开始执行的,提交后,命令缓存区将按其入队的顺序执行,使用[commandBuffer commit]提交命令。

- (void)drawInMTKView:(MTKView *)view {
    // 用于向着色器传递数据
    id <MTLCommandBuffer> commandBuffer = [self.commandQueue commandBuffer];
    
... 设置MTLRenderCommandEncoder进行Encode

    // 提交
    [commandBuffer presentDrawable:view.currentDrawable];
    [commandBuffer commit];
}

(7) MTLRenderCommandEncoder

渲染命令编码器 Render Command Encoder表示单个渲染过程中相关联的渲染状态和渲染命令,有以下功能:

  • 指定图形资源,例如缓存区和纹理对象,其中包含顶点、片元、纹理图片数据
  • 指定一个MTLRenderPipelineState对象,表示编译的渲染状态,包含顶点着色器和片元着色器的编译&链接情况
  • 指定固定功能,包括视口、三角形填充模式、剪刀矩形、深度、模板测试以及其他值
  • 绘制3D图元

由当前队列的缓冲MTLCommandBuffer根据描述器MTLRenderPassDescriptor的接口获取(这个可以通过MTKView的currentRenderPassDescriptor拿到,代表每一帧当前渲染视图的一些纹理、缓冲、大小等数据的描述器)。

id <MTLCommandBuffer> commandBuffer = [self.commandQueue commandBuffer];
MTLRenderPassDescriptor *renderDesc = view.currentRenderPassDescriptor;
if (!renderDesc) {
    [commandBuffer commit];
    return;
}
// 获取MTLRenderCommandEncoder
id <MTLRenderCommandEncoder> renderEncoder = [commandBuffer renderCommandEncoderWithDescriptor:renderDesc];

然后需要对之前提到的MTLRenderPipelineState(映射.metal文件用)、MTLTexture(读取图片获得的纹理数据)、MTLBuffer(顶点坐标和纹理坐标构成的缓冲)进行设置,最后调用drawPrimitives进行绘制,再endEncoding

id <MTLRenderCommandEncoder> renderEncoder = [commandBuffer renderCommandEncoderWithDescriptor:renderDesc];
[renderEncoder setViewport:(MTLViewport){0, 0, self.viewportSize.x, self.viewportSize.y, -1, 1}];
// 映射.metal文件的方法
[renderEncoder setRenderPipelineState:self.pipelineState];
// 设置顶点数据
[renderEncoder setVertexBuffer:self.vertices offset:0 atIndex:0];
// 设置纹理数据
[renderEncoder setFragmentTexture:self.texture atIndex:0];
// 开始绘制
[renderEncoder drawPrimitives:MTLPrimitiveTypeTriangleStrip vertexStart:0 vertexCount:self.numVertices];
// 结束渲染
[renderEncoder endEncoding];
// 提交
[commandBuffer presentDrawable:view.currentDrawable];
[commandBuffer commit];

和OpenGL一样,我们可以使用4个顶点来绘制一个矩形,修改drawPrimitives:的参数为MTLPrimitiveTypeTriangleStrip,然后顶点顺序为z字形即可。

2. Metal在OC/Swift层的渲染步骤

了解到以上用到的API后,我们就可以开始介绍一下渲染步骤了:

首先我们需要初始化,把本次渲染只需创建一次的内容初始化出来,包括:MTKView、MTLCommandQueue、MTLRenderPipelineState。

- (void)setupMTKView {
    // 初始化MTKView
    self.mtkView = [[MTKView alloc] init];
    self.mtkView.delegate = self;
    self.device = self.mtkView.device = MTLCreateSystemDefaultDevice();
    self.mtkView.frame = self.view.bounds;
    [self.view addSubview:self.mtkView];
}

- (void)setupPineline {
    // 初始化pipelineState
    MTLRenderPipelineDescriptor *pinelineDesc = [MTLRenderPipelineDescriptor new];
    id <MTLLibrary> library = [_device newDefaultLibrary];
    pinelineDesc.vertexFunction = [library newFunctionWithName:@"vertexShader"];
    pinelineDesc.fragmentFunction = [library newFunctionWithName:@"fragmentShader"];
    pinelineDesc.colorAttachments[0].pixelFormat = self.mtkView.colorPixelFormat;
    self.pipelineState = [_device newRenderPipelineStateWithDescriptor:pinelineDesc error:nil];
    
}

- (void)setupCommandQueue {
    // 初始化commandQueue
    self.commandQueue = [_device newCommandQueue];
}

然后需要预先加载好纹理数据,因为这里我们用到了图片,所以需要读取图片对应的字节

- (Byte *)loadImage:(UIImage *)image {
    CGImageRef imageRef = image.CGImage;
    
    size_t width = CGImageGetWidth(imageRef);
    size_t height = CGImageGetHeight(imageRef);
    
    Byte *data = (Byte *)calloc(width * height * 4, sizeof(Byte)); // rgba 4个字节
    
    CGContextRef context = CGBitmapContextCreate(data, width, height, 8, width * 4, CGImageGetColorSpace(imageRef), kCGImageAlphaPremultipliedLast);
    
    CGContextDrawImage(context, CGRectMake(0, 0, width, height), imageRef);
    
    CGContextRelease(context);
    
    return data;
    
}

再根据图像字节获取到id <MTLTexture>类型的纹理数据:

- (void)setupFragment {
    UIImage *image = self.image;
    MTLTextureDescriptor *textureDesc = [MTLTextureDescriptor new];
    textureDesc.pixelFormat = MTLPixelFormatRGBA8Unorm;
    textureDesc.width = image.size.width;
    textureDesc.height = image.size.height;
    self.texture = [_device newTextureWithDescriptor:textureDesc];
    
    MTLRegion region = {
        {0, 0, 0},
        {textureDesc.width, textureDesc.height, 1}
    };
    Byte *imageBytes = [self loadImage:image];
    if (imageBytes) {
        [self.texture replaceRegion:region mipmapLevel:0 withBytes:imageBytes bytesPerRow:image.size.width * 4];
        free(imageBytes);
        imageBytes = NULL;
    }
}

然后我们需要设置顶点数据,这里需要说明一下Metal的坐标系:

顶点坐标系是四维的(x, y, z, w),原点在图片的正中心。

顶点坐标系

纹理坐标系是二维的(x, y),原点在图片的左上角。

纹理坐标系

得结构体:

typedef struct {
    vector_float4 position;
    vector_float2 textureCoordinate;
} HobenVertex;

当我们需要绘制一个矩形图片时,需要将顶点坐标和纹理坐标一一对应

float heightScaling = 1.0;
float widthScaling = 1.0;
HobenVertex vertices[] = {
    // 顶点坐标 x, y, z, w  --- 纹理坐标 x, y
    { {-widthScaling,  heightScaling, 0.0, 1.0}, {0.0, 0.0} },
    { { widthScaling,  heightScaling, 0.0, 1.0}, {1.0, 0.0} },
    { {-widthScaling, -heightScaling, 0.0, 1.0}, {0.0, 1.0} },
    { { widthScaling, -heightScaling, 0.0, 1.0}, {1.0, 1.0} },
};

配置好MTKView、MTLDevice以及MTLCommandQueue后,也设置好纹理数据后,接下来我们就开始处理渲染回调了。

前文有提到,渲染回调主要是设置好MTLCommandBuffer的数据,并且commit掉,而这个过程中,主要是把纹理、顶点等数据放进.metal文件处理,获取到对应像素的颜色。

#pragma mark - MTKViewDelegate

- (void)mtkView:(nonnull MTKView *)view drawableSizeWillChange:(CGSize)size {
    // MTKView的大小改变
    self.viewportSize = (vector_uint2){size.width, size.height};
}

- (void)drawInMTKView:(MTKView *)view {
    // 用于向着色器传递数据
    id <MTLCommandBuffer> commandBuffer = [self.commandQueue commandBuffer];
    MTLRenderPassDescriptor *renderDesc = view.currentRenderPassDescriptor;
    if (!renderDesc) {
        [commandBuffer commit];
        return;
    }
    renderDesc.colorAttachments[0].clearColor = MTLClearColorMake(0.5, 0.5, 0.5, 1);
    [self setupVertex:renderDesc];
    id <MTLRenderCommandEncoder> renderEncoder = [commandBuffer renderCommandEncoderWithDescriptor:renderDesc];
    [renderEncoder setViewport:(MTLViewport){0, 0, self.viewportSize.x, self.viewportSize.y, -1, 1}];
    // 映射.metal文件的方法
    [renderEncoder setRenderPipelineState:self.pipelineState];
    // 设置顶点数据
    [renderEncoder setVertexBuffer:self.vertices offset:0 atIndex:0];
    // 设置纹理数据
    [renderEncoder setFragmentTexture:self.texture atIndex:0];
    // 开始绘制
    [renderEncoder drawPrimitives:MTLPrimitiveTypeTriangleStrip vertexStart:0 vertexCount:self.numVertices];
    // 结束渲染
    [renderEncoder endEncoding];
    // 提交
    [commandBuffer presentDrawable:view.currentDrawable];
    [commandBuffer commit];
}

简要回顾介绍一下流程图:

--- 初始化阶段 ---

  1. 配置 Device 、 Queue、MTKView(初始化阶段,只初始化一次)
  2. 配置 PipelineState (设置和.metal文件映射方法,只初始化一次)
  3. 创建资源,读取纹理MTLTexture(只初始化一次)
  4. 设置顶点MTLBuffer(最好只初始化一次)

--- 渲染阶段,drawInMTKView回调,每帧渲染一次 ---

  1. 根据Queue获取 CommandBuffer
  2. 根据CommandBuffer和RenderPassDescriptor配置 CommandBufferEncoder
  3. Encoder Buffer 【如有需要的话可以用 Threadgroups 来分组 Encoder 数据】

--- 结束,提交渲染命令,在完成渲染后,将命令缓存区提交至GPU ---

  1. 提交到 Queue 中


3. Metal在Shader层的渲染步骤

Metal Shader语言,即MSL,是基于C++ 11.0设计的,关于语言规范有个超详细的官方文档,也有别人博客总结的太长不看版,当你读到这里的时候可能会比较懵,可以再回到第一章节复习一下渲染的步骤和概念,着重看一下顶点着色器、片段着色器和纹理的概念,再继续看。这一节简单地讲讲本次需求需要的.metal文件。

1) 结构体

MSL的结构体可以自定义,但是对于渲染来说,一般至少需要这两种数据:顶点坐标(xyzw四维)、纹理坐标(xy两维),这里我们定义一个包含上述两个变量的数据结构:

typedef struct {
    float4 vertexPosition [[ position ]];
    float2 textureCoor;
} RasterizerData;

[[ position ]]是一个句柄,即声明了vertexPosition这个变量是[[ position ]]类型的,这个类型的变量表明:

  • 在顶点着色函数中,表示当前的顶点信息,类型是float4

  • 还可以表示描述了片元的窗口的相对坐标(x,y,z,1/w),即该像素点在屏幕上的位置信息

我们声明了一个结构体,这个结构体会在顶点着色器(Vertex Shader)生成,经过系统处理(形状装配、几何着色器、光栅化)后,作为结构体来到片段着色器(Fragment Shader)。

2) 顶点着色器

着色器函数和C++函数大同小异,有一个声明,有一个返回值,一个函数名,n个输入。

vertex RasterizerData vertexShader(uint vertexId [[ vertex_id ]],
                                   constant HobenVertex *vertexArray [[ buffer(0) ]]) {
    RasterizerData out;
    out.vertexPosition = vertexArray[vertexId].position;
    out.textureCoor = vertexArray[vertexId].textureCoordinate;
    return out;
}

顶点着色器以vertex为修饰符,返回RasterizerData数据结构并作为片段着色器的输入,需要输入索引和顶点缓存数组。

[[ vertex_id ]] 是顶点id标识符,即索引,他并不由开发者传递;

[[buffer(index)]] 是index的缓存类型,对应OC语言的

[renderEncoder setVertexBuffer:buffer offset:0 atIndex:index];

这里的buffer就是我们事先设置好的坐标映射:

HobenVertex vertices[] = {
    // 顶点坐标 x, y, z, w  --- 纹理坐标 x, y
    { {-widthScaling,  heightScaling, 0.0, 1.0}, {0.0, 0.0} },
    { { widthScaling,  heightScaling, 0.0, 1.0}, {1.0, 0.0} },
    { {-widthScaling, -heightScaling, 0.0, 1.0}, {0.0, 1.0} },
    { { widthScaling, -heightScaling, 0.0, 1.0}, {1.0, 1.0} },
};

我们根据OC传入的一堆HobenVertex类型的顶点和对应的索引,将其转化为MSL对应的结构体RasterizerData,顶点着色器渲染完毕。

3) 片段着色器

当系统处理好一切,返回给我们一个光栅化后的数据时,我们需要根据OC传入的纹理数据进行采样、上色。

fragment float4 fragmentShader(RasterizerData input [[ stage_in ]],
                               texture2d <float> colorTexture [[ texture(0) ]]) {
    constexpr sampler textureSampler (mag_filter::linear, min_filter::linear);
    float4 colorSample = colorTexture.sample(textureSampler, input.textureCoor);
    return float4(colorSample);
}

片段着色器以fragment为修饰符,返回float4数据结构(即该像素的rgba),需要输入光栅化处理好的数据和纹理数据。

[[ stage_in ]]是由顶点着色函数输出然后经过光栅化生成的数据,这是系统生成的,无需我们进行设置和输入。

[[ texture(index) ]]代表纹理数据,index对应OC语言设置里面的

// 设置纹理数据
[renderEncoder setFragmentTexture:texture atIndex:index];

texture2d<T, access a = access::sample>代表这是一个纹理数据,其中T可以是half、float、short、int等,access表示纹理访问权限,当access没写时,默认是sample,还可以设置为sample(可读写可采样)、read(只读)、write(可读写)。

当然我们还需要设置一个采样器去对纹理进行采样,在Metal程序中初始化的采样器必须使用constexpr修饰符声明,所以需要用constexpr sampler声明。采样器的其他设置看下图:

最后根据光栅化数据的纹理坐标进行采样即可。至此,片段着色器着色结束,我们所有的渲染流程也结束了。

四. 总结

可能看到这里,你已经懵掉了,怎么画个图片也这么难?这是很正常的,如果你一点图形渲染的知识都没有掌握的话,看完这篇文章并好好消化一下,你就可以初步认识图形渲染、Metal渲染的相关知识了。这也是我根据多篇文章摸爬滚打探索出来的一些知识,如果对你有帮助的话不妨点个赞吧~

好久好久没更新博客了,最近几个月忙,也遇到了一些小困难,需要挣扎挣扎着慢慢前行,希望自己能够放下浮躁的心,务实地成长吧!接下来有几个小目标:用Metal处理视频流、学会Metal调试、完成老大给的需求、做一些比较炫酷的特效,希望自己能继续加油!

附源码,多敲几遍就熟了:

//
//  ViewController.m
//  HobenLearnMetal
//
//  Created by Hoben on 2021/1/4.
//

#import "HobenMetalImageController.h"
#import <MetalKit/MetalKit.h>
#import "HobenShaderType.h"
#import <AVFoundation/AVFoundation.h>

typedef NS_ENUM(NSUInteger, HobenRenderingResizingMode) {
    HobenRenderingResizingModeScale = 0,
    HobenRenderingResizingModeAspect,
    HobenRenderingResizingModeAspectFill,
};

@interface HobenMetalImageController () <MTKViewDelegate>

@property (nonatomic, strong) MTKView *mtkView;

@property (nonatomic, strong) id <MTLRenderPipelineState> pipelineState;

@property (nonatomic, strong) id <MTLCommandQueue> commandQueue;

@property (nonatomic, strong) id <MTLBuffer> vertices;

@property (nonatomic, assign) NSUInteger numVertices;

@property (nonatomic, strong) id <MTLTexture> texture;

@property (nonatomic, strong) UIImage *image;

@property (nonatomic, assign) vector_uint2 viewportSize;

@property (nonatomic, weak) id <MTLDevice> device;

@end

@implementation HobenMetalImageController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.image = [UIImage imageNamed:@"reus"];
    
    [self setupMTKView];
    
    [self setupCommandQueue];
    
    [self setupFragment];
    
    [self setupPineline];
}

- (void)setupFragment {
    UIImage *image = self.image;
    MTLTextureDescriptor *textureDesc = [MTLTextureDescriptor new];
    textureDesc.pixelFormat = MTLPixelFormatRGBA8Unorm;
    textureDesc.width = image.size.width;
    textureDesc.height = image.size.height;
    self.texture = [_device newTextureWithDescriptor:textureDesc];
    
    MTLRegion region = {
        {0, 0, 0},
        {textureDesc.width, textureDesc.height, 1}
    };
    Byte *imageBytes = [self loadImage:image];
    if (imageBytes) {
        [self.texture replaceRegion:region mipmapLevel:0 withBytes:imageBytes bytesPerRow:image.size.width * 4];
        free(imageBytes);
        imageBytes = NULL;
    }
}

- (Byte *)loadImage:(UIImage *)image {
    CGImageRef imageRef = image.CGImage;
    
    size_t width = CGImageGetWidth(imageRef);
    size_t height = CGImageGetHeight(imageRef);
    
    Byte *data = (Byte *)calloc(width * height * 4, sizeof(Byte)); // rgba 4个字节
    
    CGContextRef context = CGBitmapContextCreate(data, width, height, 8, width * 4, CGImageGetColorSpace(imageRef), kCGImageAlphaPremultipliedLast);
    
    CGContextDrawImage(context, CGRectMake(0, 0, width, height), imageRef);
    
    CGContextRelease(context);
    
    return data;
    
}

- (void)setupMTKView {
    // 初始化MTKView
    self.mtkView = [[MTKView alloc] init];
    self.mtkView.delegate = self;
    self.device = self.mtkView.device = MTLCreateSystemDefaultDevice();
    self.mtkView.frame = self.view.bounds;
    [self.view addSubview:self.mtkView];
}

- (void)setupPineline {
    // 初始化pipelineState
    MTLRenderPipelineDescriptor *pinelineDesc = [MTLRenderPipelineDescriptor new];
    id <MTLLibrary> library = [_device newDefaultLibrary];
    pinelineDesc.vertexFunction = [library newFunctionWithName:@"vertexShader"];
    pinelineDesc.fragmentFunction = [library newFunctionWithName:@"fragmentShader"];
    pinelineDesc.colorAttachments[0].pixelFormat = self.mtkView.colorPixelFormat;
    self.pipelineState = [_device newRenderPipelineStateWithDescriptor:pinelineDesc error:nil];
    
}

- (void)setupCommandQueue {
    // 初始化commandQueue
    self.commandQueue = [_device newCommandQueue];
}

#pragma mark - MTKViewDelegate

- (void)mtkView:(nonnull MTKView *)view drawableSizeWillChange:(CGSize)size {
    // MTKView的大小改变
    self.viewportSize = (vector_uint2){size.width, size.height};
}

- (void)drawInMTKView:(MTKView *)view {
    // 用于向着色器传递数据
    id <MTLCommandBuffer> commandBuffer = [self.commandQueue commandBuffer];
    MTLRenderPassDescriptor *renderDesc = view.currentRenderPassDescriptor;
    if (!renderDesc) {
        [commandBuffer commit];
        return;
    }
    renderDesc.colorAttachments[0].clearColor = MTLClearColorMake(0.5, 0.5, 0.5, 1);
    [self setupVertex:renderDesc];
    id <MTLRenderCommandEncoder> renderEncoder = [commandBuffer renderCommandEncoderWithDescriptor:renderDesc];
    [renderEncoder setViewport:(MTLViewport){0, 0, self.viewportSize.x, self.viewportSize.y, -1, 1}];
    // 映射.metal文件的方法
    [renderEncoder setRenderPipelineState:self.pipelineState];
    // 设置顶点数据
    [renderEncoder setVertexBuffer:self.vertices offset:0 atIndex:0];
    // 设置纹理数据
    [renderEncoder setFragmentTexture:self.texture atIndex:0];
    // 开始绘制
    [renderEncoder drawPrimitives:MTLPrimitiveTypeTriangleStrip vertexStart:0 vertexCount:self.numVertices];
    // 结束渲染
    [renderEncoder endEncoding];
    // 提交
    [commandBuffer presentDrawable:view.currentDrawable];
    [commandBuffer commit];
}

- (void)setupVertex:(MTLRenderPassDescriptor *)renderPassDescriptor {
    
    if (self.vertices) {
        return;
    }
    UIImage *image = self.image;
    float heightScaling = 1.0;
    float widthScaling = 1.0;
    CGSize drawableSize = CGSizeMake(renderPassDescriptor.colorAttachments[0].texture.width, renderPassDescriptor.colorAttachments[0].texture.height);
    CGRect bounds = CGRectMake(0, 0, drawableSize.width, drawableSize.height);
    CGRect insetRect = AVMakeRectWithAspectRatioInsideRect(image.size, bounds);
    
    HobenRenderingResizingMode fillMode = HobenRenderingResizingModeAspect;
    
    switch (fillMode) {
        case HobenRenderingResizingModeScale: {
            widthScaling = 1.0;
            heightScaling = 1.0;
        };
            break;
        case HobenRenderingResizingModeAspect:
        {
            widthScaling = insetRect.size.width / drawableSize.width;
            heightScaling = insetRect.size.height / drawableSize.height;
        };
            break;
        case HobenRenderingResizingModeAspectFill:
        {
            widthScaling = drawableSize.height / insetRect.size.height;
            heightScaling = drawableSize.width / insetRect.size.width;
        };
            break;
    }
    
    HobenVertex vertices[] = {
        // 顶点坐标 x, y, z, w  --- 纹理坐标 x, y
        { {-widthScaling,  heightScaling, 0.0, 1.0}, {0.0, 0.0} },
        { { widthScaling,  heightScaling, 0.0, 1.0}, {1.0, 0.0} },
        { {-widthScaling, -heightScaling, 0.0, 1.0}, {0.0, 1.0} },
        { { widthScaling, -heightScaling, 0.0, 1.0}, {1.0, 1.0} },
    };
    
    self.vertices = [_device newBufferWithBytes:vertices length:sizeof(vertices) options:MTLResourceStorageModeShared];
    self.numVertices = sizeof(vertices) / sizeof(HobenVertex);
}

@end

//
//  HobenShaderType.h
//  HobenLearnMetal
//
//  Created by Hoben on 2021/1/4.
//

#ifndef HobenShaderType_h
#define HobenShaderType_h

typedef struct {
    vector_float4 position;
    vector_float2 textureCoordinate;
} HobenVertex;

#endif /* HobenShaderType_h */
//
//  Shaders.metal
//  HobenLearnMetal
//
//  Created by Hoben on 2021/1/4.
//

#include <metal_stdlib>
#import "HobenShaderType.h"

using namespace metal;

typedef struct {
    float4 vertexPosition [[ position ]];
    float2 textureCoor;
} RasterizerData;

vertex RasterizerData vertexShader(uint vertexId [[ vertex_id ]],
                                   constant HobenVertex *vertexArray [[ buffer(0) ]]) {
    RasterizerData out;
    out.vertexPosition = vertexArray[vertexId].position;
    out.textureCoor = vertexArray[vertexId].textureCoordinate;
    return out;
}

fragment float4 fragmentShader(RasterizerData input [[ stage_in ]],
                               texture2d <float> colorTexture [[ texture(0) ]]) {
    constexpr sampler textureSampler (mag_filter::linear, min_filter::linear);
    float4 colorSample = colorTexture.sample(textureSampler, input.textureCoor);
    return float4(colorSample);
}

参考文章:

Learn OpenGL中文文档

Metal入门教程(一)图片绘制

Metal 案例04:加载图片

iOS 图像渲染原理

计算机那些事(8)——图形图像渲染原理

WWDC 2018:写给 OpenGL 开发者们的 Metal 开发指南

Metal 系列教程(1)- Metal 介绍及基本使用

超详细的Metal官方文档

Metal Shader language (着色语言规范)总结

Metal学习笔记01 渲染一个三角形

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 198,154评论 5 464
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,252评论 2 375
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 145,107评论 0 327
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,985评论 1 268
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,905评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 47,256评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,978评论 3 388
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,611评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,891评论 1 293
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,910评论 2 314
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,736评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,516评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,995评论 3 301
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,132评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,447评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,034评论 2 343
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,242评论 2 339

推荐阅读更多精彩内容