Warren Moore是Apple的前工程师,最近拜访了
Swift语言用户组,并做了一个关于3D图形,Apple新的平台技术Metal的演讲。其实,自Metal面世以来,我们对它的了解也仅限于官方的一些宣传介绍,真正的用法与独特之处却知之甚少。而这次,从Warren Moore介绍中,我们选取了其中的重点部分,转化成文字与大家分享关于Metal的重要信息。
第一部分:渲染基础
什么是3D渲染?
3D渲染简单的来说就是,为几何数据模型添加视角、材质属性和灯光的一种手法。而3D图形则是由紧密的三角形态固定在一起并绘制上纹理构成。我们采用像素错觉工艺,并利用类似深度知觉和人类其他的视觉特点来构建出看上去真实的事物。
管线
固定功能管线(Fixed-Function pipeline)的意思就是硬件是可配置的,但却是不可编程的。你可以申请设定GPU的某些状态,比如管线参数或纹理的图形状态等,只是不能自己编写着色器。而可编程的管线,就可以做一些比较炫的效果。着色器就是实现图像渲染,用来替代固定功能管线的可编辑程序。分为顶点着色器和像素着色器两种,前者负责顶点的几何关系等运算,而后者则是负责片源颜色等的计算。
变换
变换涉及最多的就是透视投影的变换,在3D空间里,屏幕上显示的一切都是在一个视锥体中,一个具有远近平面切除顶部的椎体。至于透视投影变换的具体原理,大家可以自行搜索,将会获得很详尽的原理介绍。
在坐标空间之间移动
如果有一个3D场景,场景中的每个模型都可以用一个向量来确定它的位置,但如何让计算机根据这些坐标把模型正确的、有层次的画在屏幕上?这就是我们需要变换三维顶点坐标的原因,最终目的就是让GPU可以将这些三维数据绘制到二维屏幕上。 根据顶点坐标变换的先后顺序,主要有如下几个坐标空间:模型坐标空间(Object space)、世界坐标空间(World space)、观察坐标空间(Eye space)和屏幕坐标空间(Clip and Project space)。
第二部分:Metal的使用
上下文和约定
当你在编写3D移动应用时,你有很多种选择。XCode的SceneKit/SpriteKit是你摆脱GPU的最高水平的抽象化,但并不是在任何地方都很灵活。CoreAnimation和CoreGraphics水平要稍微低一点,但并不真正完全用于3D。截至目前为止,OpenGL ES一直是iOS上首屈一指的3D技术,并已转向高性能的代码。现在,我们有了Metal,它配置方面的文摘几乎为零,这就意味着,你不得不做很多的工作去启动并运行Metal,不过,这同时也为你提供了支配Metal的权利和控制范围。
设备
在iOS设备里,设备是GPU的抽象,这符合MTLDevice的协议,它们是你在Metal中处理的根对象,并且帮助构建其他的东西,其中包括纹理、缓冲区和管道的状态。创建一个是相当简单的,但是为了使用Metal去获取屏幕上的任意东西,你需要配合UIKit使用一个专门的子类CA层“CAMetalLayer”。你告知它交流的是哪个设备并为它提供一个像素格式“BGRA8Unorm”,这是基本的一个8位的颜色组件格式。
let device = MTLCreateSystemDefaultDevice()
let metalLayer = CAMetalLayer()
metalLayer.device = device
metalLayer.pixelFormat = .BGRA8Unorm
可绘是CAMetalLayer的另一方面,但它们并不是能够真正的模拟一切。Metal可以给你一个可绘制的对象,进而可以给你一个你可以画制的纹理。你通过“.texture”属性访问自己的帧缓存,被称为交换链的抽象。Metal默认三重缓冲:你可以绘制到一个表面上,将另一个复制到图形硬件上,并显示一个第三方。
渲染通道
为了清除屏幕或进行其他的操作,我们需要构建所谓的渲染通道描述符。它封装了你想要对帧缓存纹理所做的行为,以下的代码就是一个渲染通道配置的示例:
let passDescriptor = MTLRenderPassDescriptor()
passDescriptor.colorAttachments[0].texture = drawable.texture
passDescriptor.colorAttachments[0].loadAction = .Clear
passDescriptor.colorAttachments[0].storeAction = .Store
passDescriptor.colorAttachments[0].clearColor = MTLClearColorMake(0.8, 0.0, 0.0, 1.0)
命令提交流程
为了提交工作至GPU,我们以Command Queue开始,它是一个MTLCommandQueue协议,是提交工作的一个线程安全方式。为了能够在GPU上做一些实际意义上的事情,我们提交指令缓存组成的编码或渲染由指令编码器编写的指令。Command Queue只是一个串行队列,以一个有组织的方式分派工作至GPU。你可以通过多线程提交一个Command Queue,因为它是一个人固有的线程安全对象。使用以下的代码可以构建出一个:
commandQueue = device.newCommandQueue()
// To actually write commands into the queue
commandBuffer.renderCommandEncoderWithDescriptor(passDescriptor)!
// Issuing draw calls
// … fixed-function configuration …
commandEncoder.drawPrimitives(.Triangle, vertexStart:0, vertexCount:3)
// Presenting and committing
// … draw calls …
commandEncoder.endEncoding()
commandBuffer.presentDrawable(drawable)
commandBuffer.commit()
顶点&管道描述符
Metal中的渲染管道是一个实际上的对象,不仅仅是一个抽象的概念。这是一个图形状态的预编译组,包括一个顶点和片段着色器/函数,伴随着每一对着色器。管道的交换是廉价的,但创建却是昂贵的,因为他们采用着色器代码并将它编译到目标硬件中,并在GPU上运行。
为了向GPU提交几何结构,顶点描述符告知Metal顶点在内存中是如何安排的。顶点描述符需要与其他东西联合,包括一个管道描述符。
// To create a vertex descriptor
let vertexDescriptor = MTLVertexDescriptor()
vertexDescriptor.attributes[0].offset = 0;
vertexDescriptor.attributes[0].format = .Float4
vertexDescriptor.attributes[0].bufferIndex = 0
vertexDescriptor.attributes[1].offset = sizeof(Float32) * 4
vertexDescriptor.attributes[1].format = .Float4
vertexDescriptor.attributes[1].bufferIndex = 0
vertexDescriptor.layouts[0].stepFunction = .PerVertex
vertexDescriptor.layouts[0].stride = sizeof(Float32) * 8
// To create a pipeline descriptor
let pipelineDescriptor = MTLRenderPipelineDescriptor()
pipelineDescriptor.vertexDescriptor = vertexDescriptor
pipelineDescriptor.vertexFunction = vertexFunction
pipelineDescriptor.fragmentFunction = fragmentFunction
pipelineDescriptor.colorAttachments[0].pixelFormat = .BGRA8Unorm
库和函数
为了获取我们编写的顶点和分段函数,我们需要一个被称为库的对象。库是可以通过名称检索的函数合集;默认库由所有的顶点着色器组成,已经编译到你的App库中。构建一个库和函数:
let library = device.newDefaultLibrary()!
let vertexFunction = library.newFunctionWithName("vertex_func")
let fragmentFunction = library.newFunctionWithName("fragment_func")
还有意识到一个重要的事情就是,顶点着色器运行的很少,但片段着色器倒是运行的比较多。一个管道状态同步创建运行在GPU上的编译后的代码。
// a simple vertex shader
vertex OutVertex vertex_func(device InVertex *vert [[buffer(0)]],
constant Uniforms &uniforms [[buffer(1)]],
uint vid [[vertex_id]])
{
OutVertex outVertex;
outVertex.position = uniforms.rotation_matrix * vert[vid].position;
outVertex.color = vert[vid].color;
return outVertex;
}
// a simple fragment shader
fragment half4 fragment_func(OutVertex vert [[stage_in]])
{
return half4(vert.color);
}
// creating a pipeline state
pipeline = device.newRenderPipelineStateWithDescriptor(pipelineDescriptor, error:error)
移动到3D
为了能够将2D中的渲染动画移动到3D中,我们需要附加一个深度缓冲。同样,我们还需要将每个顶点的法线方向相关联,这允许我们去计算类似光线的东西。我们还将引入一个透视投影矩阵,以及一个片段着色器用来做极其普通的灯光。当你绘制并允许以任何顺序绘制时,深度缓存是与渲染缓存相关的一个纹理。
光线的基础
一个非常基本的照明形式被称为漫射照明。漫射是用来描述管线照射到表面并向各个方向散射的术语。相对比的是镜面反射,管线将会集中到一个亮点。计算漫射,你只需要曲面法线和光线的方向。这两个向量的数量积是漫射光线的强度,或表面特定点的辐射值。为了在Metal着色器中做这个,我们写了一个顶点函数采用在一个正常空间,并把它转换到世界空间。
vertex OutVertex light_vertex(device InVertex *vert [[buffer(0)]],
constant Uniforms &uniforms [[buffer(1)]],
uint vid [[vertex_id]])
{
OutVertex outVertex;
outVertex.position = uniforms.projectionMatrix *
uniforms.modelViewMatrix *
vert[vid].position;
outVertex.normal = uniforms.normalMatrix * vert[vid].normal;
return outVertex;
}
片段着色器规范化顶点法线,获取它的数量积和入射光线方向,然后浸透0到1之间。
fragment half4 light_fragment(OutVertex vert [[stage_in]])
{
float intensity = saturate(dot(normalize(vert.normal), lightDirection));
return half4(intensity, intensity, intensity, 1);
}
纹理
提到纹理,我们将每个顶点使用2D纹理坐标关联。这个使用逐像素漫射颜色替代顶点颜色。从UIImage中加载纹理数据,我们利用CG去绘制一个UIImage到一个位图的上下文,然后复制像素数据到MTLTexture中。
let textureDescriptor = MTLTextureDescriptor.texture2DDescriptorWithPixelFormat(.RGBA8Unorm,
width: Int(width),
height: Int(height),
mipmapped: true)
let texture = device.newTextureWithDescriptor(textureDescriptor)
let region = MTLRegionMake2D(0, 0, Int(width), Int(height))
texture.replaceRegion(region,
mipmapLevel: 0,
withBytes: rawData,
bytesPerRow: Int(bytesPerRow))
抽样
如果你获取纹理中的一个纹理元素的值或一个像素,你可以直接检索到它去获取颜色值。在纹理元素和像素之间没有一对一的映射,因此取样器是一个对象,知道如何去阅读一个纹理元素和这些纹理元素间的插值。
// creating a sampler state
let samplerDescriptor = MTLSamplerDescriptor()
samplerDescriptor.minFilter = .Nearest
samplerDescriptor.magFilter = .Linear
samplerState = device.newSamplerStateWithDescriptor(samplerDescriptor)
// a texturing fragment shader
fragment half4 tex_fragment(OutVertex vert [[stage_in]],
texture2d<float> texture [[texture(0)]],
sampler samp [[sampler(0)]])
{
float4 diffuseColor = texture.sample(samp, vert.texCoords);
return half4(diffuseColor.r, diffuseColor.g, diffuseColor.b, 1);
}
资源
精彩问答
Q:关于OpenGL和Metal,有什么性能数据相比较的吗?
Warren:前期宣传的Metal超十倍的渲染性能,但如果你不真正地自己编写和测试应用,那么将毫无意义。关于两者的比较,我没有任何具体的数据,但在你做类似于验证每一帧和重新编译状态时,Metal将会帮你节省很多的时间。Metal不会再每一个用例中都保持快速的状态,但在大多数应用内都还是很快的。
Q:在你第一次一起使用Swift和Metal期间,你的感受是什么?
Warren:当我找到了浮动指针的技巧时,这是一个重大的启示。不过,我有点厌烦那些你不得不做的大量严格的类型转换,但总的来说我觉得我编写的代码变少了也更加稳定。总之,获得的全是积极的印象。
Q:里面有什么类型的性能工具?
Warren:XCode中的正常调试侧边栏可以给你一些很好的洞察力,但是如果你想要更多深层次的观察,Capture GPU Frame在XCode中真是一个非常神奇的工具。它给你的不仅仅是60FPS指示符,还包括获取你在特定框架中的所有渲染回调的快照。你可以查看所有绑定GPU的对象,这是一个了不起的方式,去目测检查在共享内存中的所有对象。
Q:OpenGL是一种着色语言,那么Metal有着色语言吗?
Warren:我所用的着色器都是用Metal着色语言写的。我们基本上在Swift/Objective-C中编写客户端代码,然后运行在CPU的代码使用的是源于Metal着色语言的C++编写的。
Q:你可以保存着色器为独立的单元像OpenGL的语言那样吗?
Warren:可以。通过Metal,你将拥有更多的灵活性,因为它允许你每个资源或每个编译单元拥有多个着色器。
文章来源:
Realm 点击链接,观看视频(YouTube,需自备梯子)