基础
名词回顾
- 顶点:顶点数据是一系列顶点的集合。一个顶点(Vertex)是一个3D坐标的数据的集合。而顶点数据是用顶点属性(Vertex Attribute)表示的,它可以包含任何我们想用的数据,简单起见,我们的例子每个顶点只由一个3D位置和一些颜色值组成。
- 图元(Primitive):有了坐标和颜色,我们需要指定这些数据所表示的渲染类型,一般为点、线、或三角形。
- 顶点着色器:顶点着色器用于处理顶点属性并转换坐标。
- 光栅化:把图元映射为最终屏幕上相应的像素,生成供片段着色器(Fragment Shader)使用的片段(Fragment)。
- 片段着色器:主要目的是计算一个像素的最终颜色。
- 深度测试和混合:深度测试是判断这个像素是其它物体的前面还是后面,决定是否应该丢弃;混合是因为像素有透明度,渲染对象重叠时需要计算最终的像素颜色。
- 纹理:我们通常说的纹理,指的是一张二维的图片,把它像贴纸一样贴在视图上(采样),使得屏幕显示出我们想要的样子。但在物理上, texture 指的是 GPU 显存中一段连续的空间,用来存放图像数据。
坐标系
Metal定义了几种坐标系用以在渲染管线的不同阶段转换图形数据。 一个四维齐次向量(x,y,z,w)表示一个在裁剪空间的三维的点。一个顶点着色器生成裁剪空间的点。Metal通过w分量将x,y,z由裁剪坐标转换到标准设备坐标。
- 标准化设备坐标(Normalized Device Coordinates, NDC):Metal是左手坐标系,而OpenGL是右手坐标系。NDC将坐标转换到视窗,这些坐标系是独立于屏幕的大小的。左下角在(x,y)坐标系下是(-1.0,-1.0),右上角是(1.0,1.0)。正z表示的坐标是指向屏幕里面。z轴的可视空间在0.0(近平面)到1.0(远平面)之间。Metal渲染管线裁剪图元到这个正方体内。
- 屏幕空间坐标:光栅化阶段转换标准化设备坐标到屏幕空间坐标。(x,y)坐标在这个空间是用像素描述的。
- 纹理坐标系:纹理坐标和屏幕空间坐标相似。对于2D纹理,标准纹理坐标是x,y均为0.0到1.0之间的值,(0.0, 0.0)表示了图片数据的第一个字节(图片的左上角)。(1.0, 1.0)表示了图片的最有一个字节的数据(图片的右下角)。
Metal的API
MTLCommandQueue
由device创建,是整个app绘制的队列,而command buffer存放每次渲染的指令,即包含了每次渲染所需要的信息,直到指令被提交到GPU执行。Command queue用于创建和组织MTLCommandBuffer,其内部存在着多个command buffer,并且保证指令(command buffer)有序地发送到GPU。
render pass是指绘制一组渲染目标(纹理)的命令序列。我们用MTLRenderPassDescriptor
来配置render pass。
编码器MTLCommandEncoder
,将我们描述的高级指令,编码转换成GPU可以理解的低级指令(GPU commands),写入command buffer中,我们用到的MTLRenderCommandEncoder
是用于图形渲染任务的编码器。
render pipeline是指处理绘制命令,并把数据写入到render pass的渲染目标中,着色器方法需要添加到render pipeline中。render pipeline对应的对象是MTLRenderPipelineState
,我们需要用一个MTLRenderPipelineDescriptor
对象来配置它。
当渲染好纹理后并不会自动更新到屏幕上,在Metal中,通过显示drawable对象来显示其纹理。
使用渲染管线绘制三角形
理解Metal
的渲染管线
渲染管线是处理绘制命令并将数据写入到渲染pass的目标中的过程。渲染管线包括很多阶段,一些是可编程的着色器,以及固定或者可配置的部分,顶点shader和像素shader等是可编程,它们让你手动指定你渲染的模型会长成什么样子。
该例子主要关注渲染管道的三个主要阶段:顶点着色器、光栅化、片段着色器。我们可以用MSL
(Metal Shading Language)语言编写顶点着色器和片段着色器方法。光栅化是内置操作。 渲染的开始是绘制指令,指定顶点数和要绘制的图元类型。比如本例中的绘制指令:
// Draw the triangle
[renderEncoder drawPrimitives:MTLPrimitiveTypeTriangle vertexStart:0 vertexCount:3];
复制代码
顶点着色器为每个顶点提供数据,当足够的顶点被处理后,渲染管道会对图元进行光栅化操作,决定渲染对象中的那些像素是在图元的边界内部。片段着色器就是决定这些像素各自显示的颜色。
渲染管道中的数据如何处理
我们要为渲染管线输入数据并控制数据在渲染管线中传递。为了演示顶点着色器中的数据转换,我们输入一组以视图中心点为原点,数值代表像素值的坐标,这些坐标需要转换到Metal的坐标系中。
申明一个AAPLVertex结构,用SIMD向量存储位置和颜色。
typedef struct{
vector_float2 position;
vector_float4 color;
} AAPLVertex;
复制代码
SIMD在Metal Shading Language中很常见,你也应该在自己的工程中使用它。SIMD类型可以使一种特定类型中包含多个通道,所以申明位置类型为
vector_float2
表示它包含两个32-bit的float值(分别储存x和y坐标)。颜色用vector_float4
类型存储,所以它包含4部分:红,绿,蓝和透明度
本例中我们的输入数据是常量
static const AAPLVertex triangleVertices[] =
{
// 2D positions, RGBA colors
{ { 250, -250 }, { 1, 0, 0, 1 } },
{ { -250, -250 }, { 0, 1, 0, 1 } },
{ { 0, 250 }, { 0, 0, 1, 1 } },
};
复制代码
顶点着色器生成顶点数据,所以它会输出一个颜色值和转换后的位置值。我们为此申明一个RasterizerData结构。
struct RasterizerData{
//在顶点着色器的返回值中,[[position]] 描述符表示这个值是此顶点在裁剪空间的位置
float4 position [[position]];
// 由于这个值没有描述符,所以光栅化会根据三角形的其他顶点对该值插值,然后传递给片段着色器的某个片段S
float4 color;
};
复制代码
输出的position必须定义为vector_float4
。color值则保持输入时的结构。
你需要告诉Metal传递给光栅化的那个值提供位置数据,因为Metal没有特定的命名约定。用[[position]]
描述符声明position字段表示该字段是位置值。
顶点着色器方法
使用vertex
关键字
vertex RasterizerData vertexShader(uint vertexID [[vertex_id]],
constant AAPLVertex *vertices [[buffer(AAPLVertexInputIndexVertices)]],
constant vector_uint2 *viewportSizePointer [[buffer(AAPLVertexInputIndexViewportSize)]])
复制代码
第一个参数,vertexID
,使用[[vertex_id]]
描述符,是Metal的另一个关键字。当执行渲染指令,GPU会多次调用顶点着色器方法,为每个顶点生成唯一的值。
第二个参数,vertices
,是顶点数组,之前声明的AAPLVertex
类型。
将顶点转换到Metal的坐标系需要视图的大小,即渲染目标的像素尺寸,存储在第三个参数viewportSizePointer
。
float2 pixelSpacePosition = vertices[vertexID].position.xy;
// Get the viewport size and cast to float.
vector_float2 viewportSize = vector_float2(*viewportSizePointer);
复制代码
使用vertexID
从顶点数组中获取当前的顶点数据。 顶点着色器方法必须输出裁剪坐标,是表示3D位置的四维齐次向量(x,y,z,w)。光栅化操作分离x,y,z坐标并用w值生成3D的标准设备坐标。 因为我们的例子是2D的,所以初始化输出坐标将w值设置为1,其他设置为0。将x和y坐标除以视图大小的一般就转换到了标准设备坐标。
out.position = vector_float4(0.0, 0.0, 0.0, 1.0);
out.position.xy = pixelSpacePosition / (viewportSize / 2.0);
out.color = vertices[vertexID].color;
复制代码
片段着色器方法
光栅化操作决定渲染目标的那个像素在图元内部。只有中心点在三角形内部的像素才会被渲染。 顶点着色器方法接收光栅化的输出,为每一个position计算得到颜色值。这些片段值会由后续的渲染管线阶处理,最终写入到渲染目标上。 片段着色器方法用fragment
关键字声明,它只有一个参数,顶点着色器提供的RasterizerData
结构。[[stage_in]]
表明这是一个光栅化生成的值。
fragment float4 fragmentShader(RasterizerData in [[stage_in]]) {
// Return the interpolated color.
return in.color;
}
复制代码
光栅化阶段为每个片段生成参数并调用片段着色器方法。光栅化为每个片段生成的颜色值是三个顶点的混合,距离某个顶点越近,最终的颜色值该顶点所占的比重越大。
创建渲染管线State对象
现在着色器方法完成了,我们渲染管线来使用它们。首先,获取着色器方法MTLFunction
。接着,创建MTLRenderPipelineState
对象,用MTLRenderPipelineDescriptor
配置渲染管线
id<MTLLibrary> defaultLibrary = [_device newDefaultLibrary];
id<MTLFunction> vertexFunction = [defaultLibrary newFunctionWithName:@"vertexShader"];
id<MTLFunction> fragmentFunction = [defaultLibrary newFunctionWithName:@"fragmentShader"];
MTLRenderPipelineDescriptor *pipelineStateDescriptor = [[MTLRenderPipelineDescriptor alloc] init];
pipelineStateDescriptor.label = @"Simple Pipeline";
pipelineStateDescriptor.vertexFunction = vertexFunction;
pipelineStateDescriptor.fragmentFunction = fragmentFunction;
pipelineStateDescriptor.colorAttachments[0].pixelFormat = mtkView.colorPixelFormat;
_pipelineState = [_device newRenderPipelineStateWithDescriptor:pipelineStateDescriptor error:&error];
复制代码
渲染管线state指定的pixel format必须和渲染pass命令中指定的兼容,本例中它们都是使用的mtkView的像素格式。
设置Render Pass
创建好渲染管线后,我们要将其配置到渲染通道中,并且需要设置顶点数据和视图大小。
[renderEncoder setRenderPipelineState:_pipelineState];
[renderEncoder setVertexBytes:triangleVertices length:sizeof(triangleVertices) atIndex:AAPLVertexInputIndexVertices];
[renderEncoder setVertexBytes:&_viewportSize length:sizeof(_viewportSize) atIndex:AAPLVertexInputIndexViewportSize];
复制代码
显示绘制结果到屏幕
该方法告诉Metal当渲染命令执行结束时,Metal需要使用Core Animation显示最终的绘制结果。
[commandBuffer presentDrawable:drawable];
复制代码
使用纹理绘制图片
官方demo
我们在Metal中可以使用纹理来绘制或者处理图片。纹理是由纹理元素(一般就是像素)组成的,纹理元素的结构是由纹理的类型决定的,纹理一般是一个2D图片(甚至也有1D和3D的纹理)。本例中我们使用的纹理结构是一个由颜色数据组成的2D数组,用来保存一张图片。片段着色器通过对纹理采样为每个片段生成颜色。
在Metal中使用纹理我们要创建MTLTexture
类,它定义了纹理的格式,包括大小,纹理元素的布局方式,纹理元素的数量,以及这些纹理元素的组成方式。一旦创建,纹理的格式就不能更改了,但是我们可以通过拷贝数据到纹理来更改它的内容。
Metal并没有提过API从纹理或文件来加载图片。Metal只能创建纹理并提供方法加载或提取图片数据到纹理。我们需要自己实现图片处理代码或依赖其他的frameworks,如MetalKit, Image I/O, UIKit, or AppKit。本例实现了一个自定义纹理加载器。
加载并更改图片格式
需要创建纹理并更新其内容的情况:
- 你有一个特殊格式的图片。
- 纹理的内容需要在runtime阶段生成。
- 从服务器获取的纹理数据,需要动态更新。
本例中AAPLImage类从TGA文件加载并解析了图片数据。它将TGA文件的像素数据转换成Metal能够理解的格式。本例中,我们用图片的元数据创建纹理并把像素数据作为它的内容。
Metal需要所有的纹理拥有特定的格式MTLPixelFormat。像素格式描述了像素在纹理中的布局。本例中使用MTLPixelFormatBGRA8Unorm像素格式,每个像素32bits,每个颜色值占8bits,排列顺序为蓝、绿、红、透明度。
创建纹理
MTLTextureDescriptor *textureDescriptor = [[MTLTextureDescriptor alloc] init];
textureDescriptor.pixelFormat = MTLPixelFormatBGRA8Unorm;
textureDescriptor.width = image.width;
textureDescriptor.height = image.height;
id<MTLTexture> texture = [_device newTextureWithDescriptor:textureDescriptor];
复制代码
用MTLTextureDescriptor对象配置纹理的大小、像素格式等属性,然后调用newTextureWithDescriptor:方法创建纹理。 Metal创建MTLTexture对象并为纹理数据分配内存,但是并没有初始化,所以接下来我们要填充纹理的内容。
为纹理填充图片数据
Metal管理纹理的内存,所以我们不能获取纹理数据的指针来给它赋值,我们需要调用MTLTexture对象的方法来给它赋值。本例中我们需要将AAPLImage对象的数据传递给纹理对象。 MTLRegion对象指定了你想更新纹理的那一部分,本例中当然是整个纹理。
MTLRegion region = { { 0, 0, 0 }, {image.width, image.height, 1}};
复制代码
图片数据需要按行来读取,所以我们需要指定每行的偏移量。
NSUInteger bytesPerRow = 4 * image.width;
[texture replaceRegion:region mipmapLevel:0 withBytes:image.data.bytes bytesPerRow:bytesPerRow];
复制代码
将纹理映射到图元
将纹理应用到图元中时,片段着色器需要知道自己要用的纹理数据。所以我们要用到纹理坐标:浮点数表示的纹理位置映射到图元的位置上。
如前文所说,纹理坐标系以左上为原点,x,y方向的值均为0.0到1.0。所以我们需要在顶点着色器的输入中为纹理坐标增加字段。
typedef struct{
// Positions in pixel space. A value of 100 indicates 100 pixels from the origin/center.
vector_float2 position;
// 2D texture coordinate
vector_float2 textureCoordinate;
}AAPLVertex;
复制代码
在顶点数据中增加纹理的顶点坐标。
static const AAPLVertex quadVertices[] ={
// Pixel positions, Texture coordinates
{ { 250, -250 }, { 1.f, 1.f } },
{ { -250, -250 }, { 0.f, 1.f } },
{ { -250, 250 }, { 0.f, 0.f } },
{ { 250, -250 }, { 1.f, 1.f } },
{ { -250, 250 }, { 0.f, 0.f } },
{ { 250, 250 }, { 1.f, 0.f } },};
复制代码
在RasterizerData结构中增加纹理坐标以将其传递给片段着色器。
struct RasterizerData {
float4 position [[position]];
float2 textureCoordinate;
};
复制代码
在顶点着色器中,将纹理坐标传递给光栅化阶段。光栅化将纹理数据插入到相应的片段中。
out.textureCoordinate = vertexArray[vertexID].textureCoordinate;
复制代码
对纹理采样
片段着色器方法的参数colorTexture是纹理对象MTLTexture的引用,采样需要用到它。
fragment float4samplingShader(RasterizerData in [[stage_in]], texture2d<half> colorTexture [[ texture(AAPLTextureIndexBaseColor) ]])
复制代码
使用内建的采样方法sample()
采样纹理数据。sample()
需要两个参数:采样方式、需要采样的纹理坐标。sample()
用纹理中一个或多个像素值计算得到最终的颜色。
当绘制区域和纹理大小不一致时,就需要指定采样方式。mag_filter
指定绘制区域比纹理大时的采样方式,min_filter
指定绘制区域比纹理小时的采样方式。设置为线性采样方式linear
时,采样器通过平均纹理坐标周围的像素颜色得到采样颜色,这会使得图片显示的比较平滑。
constexpr sampler textureSampler (mag_filter::linear, min_filter::linear);
// Sample the texture to obtain a color
const half4 colorSample = colorTexture.sample(textureSampler, in.textureCoordinate);
复制代码
自定义一个Metal View
官方demo
尽管MetalKit的MTKView
提供了主要的能力使你能够快速实现Metal功能,但有时你也需要控制Metal内容的渲染。本例演示了如何实现一个继承自UIView的自定义Metal View。它使用CAMetalLayer
来呈现渲染的内容。
渲染自定义的view
一个CAMetalLayer
创建了一个可绘制对象池CAMetalDrawable
。在某个给定的时间,对象池的某个对象展示这个layer的内容。要更改layer的显示内容时,需要向layer获取一个可绘制对象,渲染完毕后,将layer的渲染内容指向该绘制对象。 当要渲染一帧时,调用layer的nextDrawable
方法来获取一个可绘制对象,drawable提供了Core Animation显示的纹理。
id<CAMetalDrawable> currentDrawable = [metalLayer nextDrawable];
// If the current drawable is nil, skip rendering this frame
if(!currentDrawable){ return;}
_drawableRenderDescriptor.colorAttachments[0].texture = currentDrawable.texture;
复制代码
实现重复渲染
为了实现重复渲染,本例配置了CADisplayLink
。CADisplayLink
是一个能让我们以和屏幕刷新率同步的频率将特定的内容画到屏幕上的定时器类。CADisplayLink
以特定模式注册到runloop后,每当屏幕显示内容刷新结束的时候,CADisplayLink
类对应的selector就会被调用一次。
- (void)setupCADisplayLinkForScreen:(UIScreen*)screen{
[self stopRenderLoop];
_displayLink = [screen displayLinkWithTarget:self selector:@selector(render)];
_displayLink.paused = self.paused;
_displayLink.preferredFramesPerSecond = 60;
}
复制代码
调试着色器
Xcode能够获取某一帧,修改着色器代码后可以实时预览。如下开启着色器调试。 如下抓取一帧
调试顶点着色器方法
抓取成功后在左侧导航栏切换到>Group by Pipeline State,然后选择Geometry查看所有三角形图元。选择某一个三角形后点击Debug按钮,Xcode就会打开相应的顶点着色器方法
调试片段着色器方法
在左侧导航栏选到attachments,点击工具栏中的放大按钮,然后调整位置选择一个像素,点击Debug按钮,Xcode就会显示绘制该像素的片段着色器方法。并且你可以逐行调试 你在左侧调用列表中选择某行代码,着色器调试程序就会实时执行到此处。右侧的变量view显示当前时间点的变量值,这在调试循环方法的时候很有用。你还可以看到变量的更多细节,点击变量值右侧的方形按钮,Xcode就会显示一个可视化面板,比如检查一个颜色值就会展示一个调色板,对于嵌套的变量类型也能检查它的内部结构。 并且你可以更改着色器代码,然后点击工具栏的刷新按钮,就能更新当前这一帧查看其效果。