【一步步学Metal引擎1】-《绘制第一个三角形》

教程 1

绘制第一个三角形

教程源码下载地址: https://github.com/jiangxh1992/MetalTutorialDemos

CSDN完整版专栏: https://blog.csdn.net/cordova/category_9734156.html

一、知识点

  • Metal渲染管线
  • 顶点缓冲
  • Metal着色器(顶点着色器和片段着色器)
  • 顶点坐标系
  • Metal Shading Language(MSL)

二、关于Metal

2.1 Metal介绍

Metal同DirectX、OpenGL、Vulkan等都属于GPU的图形API,是开发商提供给开发者的图形开发接口,他们都直接跟硬件层面对接,可调用GPU驱动,执行渲染和计算指令。Metal引擎在2014年由苹果公司向开发者引进,针对苹果A系列显卡量身定做,充分发挥硬件性能,以得天独厚的优势在苹果平台替换掉之前的OpenGL ES,成为苹果设备上唯一的底层图形硬件接口。相比于跨平台的OpenGL和微软的DX,Metal经过改进成为一种与时俱进的新型图形接口,尤其在游戏领域有着明显的应用优势,不断更新的的引擎特性为游戏引擎的优化和加速提供动力,同时对于iOS开发者学习门槛相对较低,对开发者更友好。

2.2 Metal在开发框架中的位置

Metal是最底层的应用框架,直接和硬件驱动交互,并为上层框架提供支持和服务。Metal上一层的常见框架是一些图形绘制库,包括核心图形库Core Graphics(QuartzCore)、核心动画库Core Aniamtion以及CoreImage。再顶层的就是开发者常用的应用层框架了,例如:UIKit和AppKit。

【一步步学Metal引擎1】-《绘制第一个三角形》

以UI开发为例,传统iOS开发者通常会使用UIKit框架开发UI,而UIKit底层的绘制是依赖于CoreGraphics图形库的支持,CoreGraphics则是对Metal的图形绘制封装。命令交给Metal后,Metal即可调动GPU驱动,从而让GPU开始按要求进行工作,GPU绘制的结果最终会绘制到屏幕上。

另外对于游戏开发者,可以使用官方基于Metal封装的SceneKit、SpritKit等游戏引擎库进行开发,也可以使用第三方的游戏引擎开发,例如Unity等最终的底层图形支持都是回到Metal上(之前是OpenGL ES),通过Metal向GPU设备提交绘制指令。开发者可以通过已有的成熟框架间接使用Metal,也可以使用官方提供的Metal API直接在Metal上进行底层图形开发。

三、光栅化可编程渲染管线

传统的光栅化渲染管线是目前主流的渲染管线,并经历了从固定渲染管线到可编程渲染管线的演变。固定渲染管线指的是硬件开发商已经将渲染流程固定写死,不可二次开发,渲染流程不可改变,功能固定,开发者只能改变一些参数。而之后为了增加开发灵活性,管线的部分阶段向开发者开放,开发者可通过在可编程阶段编写着色函数代码,来开发丰富的图形功能和效果。

目前可编程的几个管线阶段主要包括:顶点处理阶段、几何处理阶段和片段处理阶段。

【一步步学Metal引擎1】-《绘制第一个三角形》
顶点处理器阶段负责处理经过管线的每一个顶点的顶点着色代码,即vertex shader。顶点着色器阶段不关心渲染的图元的拓扑结构如何,另外不可以在顶点着色处理器阶段删除丢弃顶点,每个顶点有且只有一次经过顶点处理器,要经过变换后继续进入管线的下一步。

下一个阶段是几何处理器阶段。这个阶段着色器可获取图元的完整数据,包括所有的顶点数据和相邻顶点的数据。这个阶段的主要特点是可以改变顶点的数量,可以增加顶点或者删除顶点,典型的应用场景是在曲面细分技术中的应用,通过一定算法逻辑合理的增加更多的顶点,使模型更加精细。几何处理阶段是可选的阶段,一般情况下开发者不会在此写代码,可以默认跳过。默认情况则是直接将顶点处理阶段的数据继续往下传递,不做修改。

之后就进入了Clip裁剪阶段以及裁剪后的片段处理阶段。裁剪阶段是管线的一个固定模块,不需要开发者关心,会自动将有效范围之外的图元顶点裁除掉,留下可见的顶点并变换到屏幕空间。有效范围指的是一个单位化的盒子空间,盒子外的顶点不可能出现在屏幕上,超出屏幕边界以及前后边界太近太远的都是不可见顶点。

之后光栅器会根据可见图元的结构将他们渲染到屏幕上。片段着色阶段,会对每个像素执行片段着色函数,开发者可在片段着色器中对每个像素计算颜色。(光栅化的详细过程和原理参考文章:图形流水线中光栅化原理与实现

通常开发中,我们面对的主要是顶点着色阶段和片段着色阶段。在顶点着色器中主要进行顶点的MVP变换,在片段着色器中主要进行纹理采样,光照计算等。

四、Metal框架结构

Metal提供的API是面向对象的结构,支持Swift和Objective-C语言。框架中一些重要的对象概念例如:commandbuffer、renderCommandEncoder、renderPassDescriptor、renderPipelineState等以及他们的用法需要开发者了解和熟悉。

【一步步学Metal引擎1】-《绘制第一个三角形》

  • MTLDevice:开发者使用Metal进行图形开发期间,首先要获取设备上下文device,device指的就是设备的GPU,获取设备对象的方法很简单,直接调用接口:MTLCreateSystemDefaultDevice()即可。

  • MTLCommandQueue: commandQueue是device对象下创建的指令序列,创建之后即一直存在于整个应用周期间,被重复使用。commandQueue是用来组织后面的commandBuffer的,组织commandBuffer有序的提交在GPU上执行。commandQueue是线程安全的,支持多个commandBuffer异步编码和提交,是GPU并发编程的一部分。

  • MTLCommandBuffer:commandBuffer是一个命令缓冲,保存编码后的渲染指令,提交给GPU去执行。commandBuffer提交之前是要用后面的renderCommandEncoder对象编码指令填充到commandBuffer中的。commandBuffer也支持多个异步的renderCommandEncoder并发工作。

  • MTLRenderCommandEncoder:renderCommandEncoder对象是为一个render pass编码指令的,常见的包括设置渲染状态:renderPipeLineState,设置着色器的texture资源,设置顶点着色器和片段着色器的数据buffer等。

  • MTLRenderPipelineState:renderPipeLineState对象用来配置一个render pass的渲染状态,包括着色器函数等。MTLRenderPipelineState对象和前面介绍的对象的创建都是比较耗费资源的,通常都是在流程的开始全局地创建并在之后重复利用,避免频繁的创建和销毁。

  • 最后,Metal框架中还有一些Descriptor对象,例如MTLRenderPipelineState的MTLRenderPipelineDescriptor,是用来描述和配置MTLRenderPipelineState的参数,用来创建MTLRenderPipelineState对象的。

五、Demo源码分析:使用Metal绘制一个三角形

5.1 Metal开发环境搭建

Xcode创建一个iOS平台的Game工程,框架渲染Metal,就得到了一个官方的Metal游戏demo,demo运行起来我们的metal开发环境就搭建好了。相比于OpenGL,DX等图形接口要配置各种插件库,复杂的环境搭建让人崩溃,Metal的环境高度整合,对于新手来说门槛降低一大截,对新人十分友好。

【一步步学Metal引擎1】-《绘制第一个三角形》【一步步学Metal引擎1】-《绘制第一个三角形》
官方的默认demo是绘制一个旋转的cube,对于本教程这一章来说还是太复杂了,这个demo已经包含了内置模型加载,纹理贴图,UniformBuffer,坐标变换等知识点。这里本章的demo中对原demo进一步做了减法简化,只绘制一个简单的三角形,用来分析和讲解Metal引擎最基本的一些知识点。

5.2 源码分析

5.2.1 GameViewController.m

_view = (MTKView *)self.view;

这里是在一个普通的UIViewController中的代码,我们是用Metal在这个UIViewController所在的UIView中进行绘制,这里要将UIView强制转为MetalKit框架中的MTKView,MTKView是UIView的子类,是Metal所能操作的类对象。

_view.device = MTLCreateSystemDefaultDevice();

MTKView中定义了MTLDevice的引用,这里通过MTLCreateSystemDefaultDevice()接口获取设备上下文的引用,用于后续的渲染过程。

// 初始化渲染器,设置渲染器的渲染对象为_view
    _renderer = [[Renderer alloc] initWithMetalKitView:_view];
    // _view尺寸变化事件,传递给render渲染器
    [_renderer mtkView:_view drawableSizeWillChange:_view.bounds.size];
    // 设置MTKView的delegate为_render,在_render中处理drawableSizeWillChange回调事件
    _view.delegate = _renderer;

Renderer是自定义的一个渲染器类,几种在这个类里面写我们渲染过程的代码,初始化创建的时候要把MTKView传进去,渲染的结果最后要绘制到MTKView上。Render实现了MTKView的代理回调事件,监听这个MTKView的视口变化,从而调整渲染的屏幕尺寸,MTKView相当于一块画布。

5.2.2 Renderer.m

    view.depthStencilPixelFormat = MTLPixelFormatDepth32Float_Stencil8;
    view.colorPixelFormat = MTLPixelFormatBGRA8Unorm_sRGB;
    view.sampleCount = 1;

这里首先设置了view默认的深度模板texture和颜色texture的像素格式。颜色texture指的是缓存一帧渲染结果的framebuffer,保存的是color buffer颜色数据。而深度模板texture不同通道保存了depth buffer深度数据和stencil buffer模板数据。(关于color buffer、depth buffer、stencil buffer的概念不了解的请自行查询)

sampleCount指的是每个像素的颜色采样个数,正常情况每个像素只采样一个,而在某些情况下,例如需要实现MSAA等抗锯齿算法的时候,则可能将采样数设置为4或者更多。

    id<MTLLibrary> defaultLibrary = [_device newDefaultLibrary];

    id <MTLFunction> vertexFunction = [defaultLibrary newFunctionWithName:@"vertexShader"];
    id <MTLFunction> fragmentFunction = [defaultLibrary newFunctionWithName:@"fragmentShader"];

MTLLibrary是用来编译和管理metal shader的,它包含了Metal Shading Language的编译源码,会在程序build过程中或者运行时编译shader文本。.metal文件中的shader代码实际上是text文本,经过MTLLibrary编译后成为可执行的MTLFunction函数对象。上面代码创建编译了顶点着色器函数和片段着色器函数。另外还有kernel函数,即computer shader,用于GPU通用并行计算。

device的newDefaultLibrary管理的是xcode工程中的.metal文件,可识别工程目录下的.metal文件中的vertex函数、fragment函数和kernel函数。

    _pipelineState = [_device newRenderPipelineStateWithDescriptor:pipelineStateDescriptor error:&error];

这里创建了绘制我们三角形的管线状态对象_pipelineState,创建_pipelineState之前需要定义一个它的Descriptor,用来配置这个render pass的一些参数,最主要是设置着色器函数。

    _depthState = [_device newDepthStencilStateWithDescriptor:depthStateDesc];

这里定义了一个深度模板状态对象,用来配置当前render pass的深度和模板配置操作。例如可以设置是否写入深度缓冲,深度测试的compare模式等。

    _commandQueue = [_device newCommandQueue];

这里使用设备上下文创建了全局唯一的指令队列对象。至此我们完成了渲染流程中的一些必要对象的创建和初始化。

    // 顶点buffer
    static const Vertex vert[] = {
        {{0,1.0}},
        {{1.0,-1.0}},
        {{-1.0,-1.0}}
    };
    vertexBuffer = [_device newBufferWithBytes:vert length:sizeof(vert) options:MTLResourceStorageModeShared];

渲染对象创建好了,现在我们准备渲染模型数据。由于我们只需要绘制一个简单的三角形,所以这里直接创建一个顶点缓冲,并设置顶点坐标。单位坐标系的原点位于中心,坐标范围在单位1盒子内。代码中的三个顶点坐标对应如下图:

【一步步学Metal引擎1】-《绘制第一个三角形》

- (void)drawInMTKView:(nonnull MTKView *)view
{...}

drawInMTKView是我们MTKView的一个代理回调函数,每一帧之前会执行,我们在这个函数里编写每一帧的指令代码。

    id <MTLCommandBuffer> commandBuffer = [_commandQueue commandBuffer];

这里获取我们commandQueue的默认commandBuffer对象。

    MTLRenderPassDescriptor* renderPassDescriptor = view.currentRenderPassDescriptor;

MTLRenderPassDescriptor是一个很重要的descriptor类,它是用来设置我们当前pass的渲染目标(render target)的,这里我们使用view默认的配置,只有一个渲染默认的目标。在一些其他渲染技术例如延迟渲染中,需要使用这个descriptor配置MRT,这里不深入介绍。

        id <MTLRenderCommandEncoder> renderEncoder =
        [commandBuffer renderCommandEncoderWithDescriptor:renderPassDescriptor];
        renderEncoder.label = @"MyRenderEncoder";

        [renderEncoder pushDebugGroup:@"DrawBox"];
        [renderEncoder setRenderPipelineState:_pipelineState];
        [renderEncoder setDepthStencilState:_depthState];
        [renderEncoder setVertexBuffer:vertexBuffer offset:0 atIndex:0];
        [renderEncoder drawPrimitives:MTLPrimitiveTypeTriangle vertexStart:0 vertexCount:3];
        [renderEncoder popDebugGroup];

        [renderEncoder endEncoding];

这里使用view默认的renderPassDescriptor创建renderCommandEncoder,来编码我们的渲染指令。pushDebugGroup和popDebugGroup只是做一个指令阶段的标记,方便我们在截帧调试的时候观察,不是很重要。代码中,我们使用renderCommandEncoder设置了渲染流程,设置了管线状态对象,和深度模板状态对象,传入了我们的顶点缓冲数据,最后调用一次drawcall绘制三角形。[renderEncoder endEncoding]标示当前render pass指令结束。

        [commandBuffer presentDrawable:view.currentDrawable];

这行代码表示当前的渲染目标设置为我们MTKView的framebuffer,将渲染结果绘制到视图上。

    [commandBuffer commit];

最后提交commandBuffer到commandQueue,等待被GPU执行。

5.2.3 Shaders.metal

shader着色器文件中我们要编写Metal Shading Language(MSL)代码。这里我们其实并没有做实质性的工作,只是按照流程在vertex shader中将顶点坐标传递到管线的下个阶段。在fragment shader中我们统一返回一个默认的红色,使三角形为红色。

typedef struct
{
    float4 position [[position]];
    float2 texCoord;
} ColorInOut;

vertex ColorInOut vertexShader(constant Vertex *vertexArr [[buffer(0)]],
                               uint vid [[vertex_id]])
{
    ColorInOut out;

    float4 position = vector_float4(vertexArr[vid].pos, 0 , 1.0);
    out.position = position;

    return out;
}

fragment float4 fragmentShader(ColorInOut in [[stage_in]])
{
    return float4(1.0,0,0,0);
}

在ColorInOut结构体中,我们定义了从vertex shader传递给下个阶段的数据。[[position]]是MSL中语义绑定的语法,用两个中括号以及其中的属性关键词表示。[[position]]表示的是vertex shader传递给下个阶段的顶点坐标数据。

这里在vertex shader函数中参数传进来了我们的顶点缓冲数组,包含了所有的顶点数据。通过[[vertex_id]]语义我们获取了当前顶点的id,也即是顶点缓冲的顶点index。

Vertex是我们定义的shader数据结构:

typedef struct
{
    vector_float2 pos;
} Vertex;

这个结构要和我们的顶点缓冲数据对应。这里指定义了坐标数据,这个结构还可以后续扩展,因为我们的顶点缓冲可能还包含normal发现数据,uv纹理坐标以及切线等数据。

顶点着色器中我们对顶点做了简单处理,float2要扩展为float4,z坐标设置为0。第四个w分量设置为1.0。片段着色函数中我们简单返回了红色的颜色值(1.0,0,0,0)。

六、运行效果

这里横屏运行效果如下。可以竖屏观察效果,理解顶点坐标在单位盒子空间是如何映射到屏幕空间的。

注意需要在真机上运行代码,因为Metal2不支持在模拟器上运行。

【一步步学Metal引擎1】-《绘制第一个三角形》

【一步步学Metal引擎1】-《绘制第一个三角形》【一步步学Metal引擎1】-《绘制第一个三角形》 Mr_厚厚 博客专家 发布了109 篇原创文章 · 获赞 403 · 访问量 88万+ 他的留言板 关注
上一篇:HDU 4003 Find Metal Mineral


下一篇:SqlServer表中两条完全相同的记录,如何删除其中1条