本案例的目的在于理解如何加载TGA图片
,以及加载PNG/JPG图片
加载TGA图片
加载TGA图片的效果如图所示
加载TGA效果图
整体案例流程图如下所示
加载TGA整体流程
准备工作
在加载图片之前,需要作以下准备工作:
- UI视图控制器类
- OC与OC的.h桥接文件
- CJLImage类
- 创建metal文件
- 创建自定义render渲染循环类
UI视图控制器类
在viewDidLoad函数中创建MTKView
对象、自定义render对象,并设置view的代理为render,其流程与Metal 入门级02:加载三角形中是一致的,不再过多说明
OC与C的桥接文件
创建metal文件与OC通用的索引枚举
(包括顶点数据索引、纹理索引) 和 顶点数据的结构体
typedef enum CJLVertexInputIndex { // 顶点 CJLVertexInputIndexVertices = 0, // 视图大小 CJLVertexInputIndexViewportSize = 1, }CJLVertexInputIndex; //纹理索引:用于往片元着色器传递纹理数据的索引 typedef enum CJLTextureIndex { CJLTextureIndexBaseColor = 0 }CJLTextureIndex; //顶点数据结构体:顶点/颜色值 typedef struct { // 像素空间的位置 // 像素中心点(100,100) // 顶点坐标 vector_float2 position; // 2D 纹理 // 纹理坐标 vector_float2 textureCoordinate; }CJLVertex;
CJLImage类
该类是将TGA转换为位图的工具类。由于平常的开发中很少去加载TGA文件,一般都是PNG/JPG文件,所以TGA文件转换成位图,其本质是将TGA文件的数据复制到BGRA图像数据指针指向的内存中,有兴趣的可以在文末的完整代码中去深入研究。
创建metal文件
主要是1个结构体+两个函数,结构体中包含顶点坐标 和纹理坐标,作为定点着色器的输出以及片元着色器的输入
//返回值结构体:顶点着色器输出和片元着色器输入(相当于OpenGL ES中的varying修饰的变量,即桥接) typedef struct { // 顶点坐标 float4 clipSpacePosition [[position]]; // 纹理坐标 float2 textureCoordinate; }RasterizerData;
下面说说两个着色函数的执行流程
- 顶点着色函数
主要是将顶点坐标归一化处理,并将处理后的顶点坐标和纹理坐标输出,经过metal的图元装配和光栅化处理,将顶点数据传入片元着色器,其流程图如下
vertex RasterizerData vertexShader(uint vertexID [[vertex_id]], constant CJLVertex *vertexArray [[buffer(CJLVertexInputIndexVertices)]], constant vector_uint2 *viewportSizePointer [[buffer(CJLVertexInputIndexViewportSize)]]) { /* 处理顶点数据: 1) 执行坐标系转换,将生成的顶点剪辑空间写入到返回值中. 2) 将顶点颜色值传递给返回值 */ // 1、定义out RasterizerData out; // 2、初始化输出剪辑空间位置,将w改为2.0,实际运行结果比1.0小一倍 out.clipSpacePosition = vector_float4(0.0, 0.0, 0.0, 1.0); // 3、获取当前顶点坐标的xy,因为是2D图形 // 索引到我们的数组位置以获得当前顶点 // 我们的位置是在像素维度中指定的. float2 pixelSpacePosition = vertexArray[vertexID].position.xy; //将vierportSizePointer 从verctor_uint2 转换为vector_float2 类型 vector_float2 viewportSize = vector_float2(*viewportSizePointer); // 4、顶点坐标归一化处理 //每个顶点着色器的输出位置在剪辑空间中(也称为归一化设备坐标空间,NDC),剪辑空间中的(-1,-1)表示视口的左下角,而(1,1)表示视口的右上角. //计算和写入 XY值到我们的剪辑空间的位置.为了从像素空间中的位置转换到剪辑空间的位置,我们将像素坐标除以视口的大小的一半. //可以使用一行代码同时分割两个通道。执行除法,然后将结果放入输出位置的x和y通道中 out.clipSpacePosition.xy = pixelSpacePosition / (viewportSize / 2.0); out.clipSpacePosition.z = 0.0f; out.clipSpacePosition.w = 1.0f; //把我们输入的颜色直接赋值给输出颜色. 这个值将于构成三角形的顶点的其他颜色值插值,从而为我们片段着色器中的每个片段生成颜色值. // 纹理坐标桥接 out.textureCoordinate = vertexArray[vertexID].textureCoordinate; //完成! 将结构体传递到管道中下一个阶段: return out; }
- 片元着色函数
主要是通过采样器获取纹素,相当于GLSL中内建函数texture2D
,在metal中是通过纹理的sample
函数获取,主要流程图如下
fragment float4 fragmentShader(RasterizerData in [[stage_in]], texture2d<half> colorTexture [[texture(CJLTextureIndexBaseColor)]]) { // 当texture2d没有写access时,默认是sampler, // 设置采样器:过滤方式 constexpr sampler textureSampler(mag_filter::linear, min_filter::linear); // 读取纹素(即纹理对应像素点的颜色值),相当于GLSL中的内建函数texture2D // half4取决于 texture2d<half>中的half ,由于颜色是RGBA,所以是half4 // GLSL中属性的设置都是通过状态机, // 而metal中属性的设置是一个对象的思维,都是纹理采样的属性设置 const half4 colorSampler = colorTexture.sample(textureSampler, in.textureCoordinate); // 进行灰度/... // 将half4 类型转换为 float4类型,如果不想这么麻烦,也可以将texture2d<half>中的half改为float,这样颜色的类型就是float4了 return float4(colorSampler); }
创建自定义render渲染循环类
苹果建议将渲染循环单独写成一个类,处理metal的渲染及委托事件,其中TGA文件的加载也是在render中处理的,它会将处理好的顶点数据及纹理图片传入着色器,着色器处理完成后会绘制并显示到屏幕上
下面着重说明render中TGA文件的加载
渲染循环类
从整体的流程图中得知,render类中的函数主要分为两大类
- initWithMetalKitView函数:初始化
- MTKViewDelegate代理方法:处理view的委托事件
initWithMetalKitView函数
该函数的流程图如下
主要分为以下四步:
- 初始化GPU设备:通过传入的view获取获取GPU的使用权限
setupVertex
函数:设置顶点相关操作setupPipeLine
函数:设置渲染管道相关操作setupTexture
函数:加载TGA文件
setupVertex函数
主要是出初始化顶点数据,包括顶点坐标和纹理坐标,当顶点坐标的范围不是-1~1时,是位于物体坐标系
,需要在metal文件的顶点着色器中作归一化处理,并且将顶点数据存储到MTLBuffer
对象中,GPU函数流程如下
对应的代码如下
//设置顶点相关操作 - (void) setupVertex{ // 1、根据顶点/纹理坐标建立一个MTLBuffer static const CJLVertex quadVertices[] = { // 不是-1~1的都是物体坐标系 //像素坐标,纹理坐标 { { 250, -250 }, { 1.f, 0.f } }, { { -250, -250 }, { 0.f, 0.f } }, { { -250, 250 }, { 0.f, 1.f } }, { { 250, -250 }, { 1.f, 0.f } }, { { -250, 250 }, { 0.f, 1.f } }, { { 250, 250 }, { 1.f, 1.f } }, }; // 2、创建我们的顶点缓冲区,并用我们的Qualsits数组初始化它 _vertexBuffer = [_device newBufferWithBytes:quadVertices length:sizeof(quadVertices) options:MTLResourceStorageModeShared]; // 3、通过将字节长度除以每个顶点的大小来计算顶点的数目 _numVertices = sizeof(quadVertices) / sizeof(CJLVertex); }
setupPipeLine函数
主要是渲染管道相关的初始化工作,对应的流程图如下
渲染管道的初始化分为以下几部分
- 加载metal文件
- 配置渲染管道
- 创建渲染管线对象
- 设置commandQueue命令对象
这几个步骤在之前的案例中均有所说明,这里只是将相关的操作提取出来单独封装成一个方法,不再详细说明,代码如下
//设置渲染管道相关操作 - (void)setupPipeLine{ // 1、创建渲染管道 //从项目中加载.metal文件,创建一个library id<MTLLibrary> defaultLibrary = [_device newDefaultLibrary]; //从库中加载顶点函数 id<MTLFunction> vertexFunction = [defaultLibrary newFunctionWithName:@"vertexShader"]; //从库中加载片元函数 id<MTLFunction> fragmentFunction = [defaultLibrary newFunctionWithName:@"fragmentShader"]; // 2、配置用于创建渲染管道状态的管道 MTLRenderPipelineDescriptor *renderPipelineDescriptor = [[MTLRenderPipelineDescriptor alloc] init]; //管道名称 renderPipelineDescriptor.label = @"Texturing Pipeline"; //可编程函数,用于处理渲染过程中的各个顶点 renderPipelineDescriptor.vertexFunction = vertexFunction; //可编程函数,用于处理渲染过程总的各个片段/片元 renderPipelineDescriptor.fragmentFunction = fragmentFunction; //设置管道中存储颜色数据的组件格式 renderPipelineDescriptor.colorAttachments[0].pixelFormat = cjlMTKView.colorPixelFormat; // 3、创建并返回渲染管线对象 & 判断是否创建成功 NSError *error; _pipelineState = [_device newRenderPipelineStateWithDescriptor:renderPipelineDescriptor error:&error]; if (!_pipelineState) { NSLog(@"Failed to created pipeline state, error %@", error); } // 4、使用device创建commandQueue _commandQueue = [_device newCommandQueue]; }
setupTexture函数
这个方案才是本案例的重点,其功能是加载TGA图片,流程如下
TGA图片的加载分为以下几步
- 获取TGA文件
主要是TGA文件通过转换为CJLImage对象,即纹理图片转换为位图
// 1、获取TGA文件路径 --- TGA文件解压 NSURL *imageFileLocation = [[NSBundle mainBundle] URLForResource:@"circle" withExtension:@"tga"]; //将tag文件->CJLImage对象 CJLImage *image = [[CJLImage alloc] initWithTGAFileAtLocation:imageFileLocation]; //判断图片是否转换成功 if (!image) { NSLog(@"Failed to create the image from:%@",imageFileLocation.absoluteString); return; }
- 创建纹理描述对象 & 纹理对象
纹理对象通过创建的纹理描述对象以及解压的位图生成,其中需要设置纹理描述对象的相关属性
// 2、创建纹理描述对象 & 设置属性 --- CJLImage --> 纹理(即位图变成纹理对象) MTLTextureDescriptor *textureDescriptor = [[MTLTextureDescriptor alloc] init]; //表示每个像素有蓝色,绿色,红色和alpha通道.其中每个通道都是8位无符号归一化的值.(即0映射成0,255映射成1); //位图信息 textureDescriptor.pixelFormat = MTLPixelFormatBGRA8Unorm; //设置纹理的像素尺寸,即纹理的分辨率 textureDescriptor.width = image.width; textureDescriptor.height = image.height; // 3、创建纹理对象:使用描述符从设备中创建纹理 _texture = [_device newTextureWithDescriptor:textureDescriptor];
- 将纹理图片复制到texture对象中
在复制纹理图片之前,首先需要计算图像每行的字节数,以及设置纹理对应的像素区域region,region的定义如下,其中包含两个结构体对象origin和size,其中origin表示开始的顶点坐标(x,y,z),size表示尺寸(宽度、高度、深度)
typedef struct { MTLOrigin origin; //开始位置x,y,z MTLSize size; //尺寸width,height,depth } MTLRegion;
然后通过replaceRegion: mipmapLevel: withBytes: bytesPerRow:
函数将图片复制到纹理对象中
//计算图像每行的字节数 NSUInteger bytesPerRow = 4 * image.width; // 4、创建MTLRegion结构体 /* typedef struct { MTLOrigin origin; //开始位置x,y,z MTLSize size; //尺寸width,height,depth } MTLRegion; */ //MLRegion结构用于标识纹理的特定区域。 demo使用图像数据填充整个纹理;因此,覆盖整个纹理的像素区域等于纹理的尺寸。 MTLRegion region = { {0,0,0}, {image.width, image.height, 1}, }; // 5、复制图片数据到texture /* 将图片复制到纹理0中(即用纹理替换region表示的区域) - (void)replaceRegion:(MTLRegion)region mipmapLevel:(NSUInteger)level withBytes:(const void *)pixelBytes bytesPerRow:(NSUInteger)bytesPerRow; 参数1-region:像素区域在纹理中的位置 参数2-level:从零开始的值,指定哪个mipmap级别是目标。如果纹理没有mipmap,请使用0。 参数3-pixelBytes:指向要复制图片的字节数 参数4-bytesPerRow:对于普通或压缩像素格式,源数据行之间的跨度(以字节为单位)。对于压缩像素格式,跨度是从一排块的开头到下一行的开始的字节数。 */ [_texture replaceRegion:region mipmapLevel:0 withBytes:image.data.bytes bytesPerRow:bytesPerRow];****
MTKViewDelegate代理方法
delegate的代理方法有两种,这里主要是说明绘制drawInMTKView
代理方法,其流程图如下
drawInMTKView代理方法流程
流程图中除了传递数据
以外,其他的步骤与Metal 案例03:大批量顶点数据的图形渲染案例中是一模一样的,这里不做介绍,着重说明下传递数据这部分
向着色器中传递的数据有三种
1)顶点数据:包含顶点坐标和纹理坐标,由于顶点数据是存储在缓存区中的,所以需要通过setVertexBuffer
函数传递到顶点着色函数中
2)视图大小数据:需要通过setVertexBytes
函数传递到顶点着色器中
3)纹理图片:将纹理对象加载到GPU中,需要通过setFragmentTexture
函数传递到片元着色函数,通过采样器读物纹素,加载TGA图片
传递数据对应的代码如下
/* 需要传递的数据有以下三种: 1)顶点数据、纹理坐标, 2)viewportSize视图大小 3)纹理图片 */ //将数据加载到MTLBuffer (即metal文件中的顶点着色函数)--> 顶点函数 [commandEncoder setVertexBuffer:_vertexBuffer offset:0 atIndex:CJLVertexInputIndexVertices]; //将数据加载到GPU(即metal文件中的顶点着色函数) --> 视图大小 [commandEncoder setVertexBytes:&_viewportSize length:sizeof(_viewportSize) atIndex:CJLVertexInputIndexViewportSize]; //将纹理对象传递到片元着色器(即metal中的片元着色函数) -- 纹理图片 [commandEncoder setFragmentTexture:_texture atIndex:CJLTextureIndexBaseColor];
加载PNG/JPG图片
加载PNG/JPG图片的效果如图所示
整体案例流程图如下所示
加载PNG/JPG图片整体流程
与加载TGA图片相比,在原代码基础上,需要将setupTexture
函数修改为setupTexturePNG
函数,下面着重说明下setupTexturePNG
函数
setupTexturePNG函数
该函数的功能是加载PNG/JPG图片,其加载的流程如图所示
除了获取图片以及loadImage函数
,其他步骤与加载TGA图片时,步骤是一致的,而loadImage函数也不是新的新的内容,在OpenGL ES的案例OpenGL ES 案例04:GLSL加载图片中的setupTexture函数中就已经讲解过是如何将PNG/JPG图片加载成位图的,主要是通过CGContextRef
对象将图片进行重绘,解压成位图数据的,图片解压为位图的流程如图所示
//加载纹理TGA文件 - (void)setupTexturePNG{ // 1、获取图片 UIImage *image = [UIImage imageNamed:@"mouse.jpg"]; // 2、创建纹理描述符 MTLTextureDescriptor *textureDescriptor = [[MTLTextureDescriptor alloc] init]; //表示每个像素有蓝色,绿色,红色和alpha通道.其中每个通道都是8位无符号归一化的值.(即0映射成0,255映射成1); textureDescriptor.pixelFormat = MTLPixelFormatRGBA8Unorm; //设置纹理的像素尺寸 textureDescriptor.width = image.size.width; textureDescriptor.height = image.size.height; // 3、使用纹理描述符创建纹理 _texture = [_device newTextureWithDescriptor:textureDescriptor]; // 4、创建MTLRegion对象 //MLRegion结构用于标识纹理的特定区域。 demo使用图像数据填充整个纹理;因此,覆盖整个纹理的像素区域等于纹理的尺寸。 /* typedef struct { MTLOrigin origin; //开始位置x,y,z MTLSize size; //尺寸width,height,depth (即宽、高、深度) } MTLRegion; */ MTLRegion region = { {0, 0, 0}, {image.size.width, image.size.height, 1}, }; // 5、获取纹理图片:通过context重绘获取纹理图片 Byte *imageBytes = [self loadImage:image]; // 6、UIImage的数据需要转成二进制才能上传,且不用jpg、png的NSData if (imageBytes) { //将纹理图片复制到texture [_texture replaceRegion:region mipmapLevel:0 withBytes:imageBytes bytesPerRow:4 * image.size.width]; //释放 free(imageBytes); imageBytes = NULL; } } //从UIImage 中读取Byte 数据返回 -- png/jpg 都是通过context重绘 解压成位图 - (Byte *)loadImage:(UIImage *)image{ // 1、将UIImage转换为CGImageRef CGImageRef spriteImage = image.CGImage; // 2、读取图片的大小 size_t width = CGImageGetWidth(spriteImage); size_t height = CGImageGetHeight(spriteImage); // 3、计算图片字节数 Byte *spriteData = (Byte *)calloc(width * height * 4, sizeof(Byte)); // 4、创建context CGContextRef spriteContext = CGBitmapContextCreate(spriteData, width, height, 8, width*4, CGImageGetColorSpace(spriteImage), kCGImageAlphaPremultipliedLast); // 5、在context上绘图 CGContextDrawImage(spriteContext, CGRectMake(0, 0, width, height), spriteImage); // 6、图片翻转过来 CGRect rect = CGRectMake(0, 0, width, height); CGContextTranslateCTM(spriteContext, 0, rect.size.height); CGContextScaleCTM(spriteContext, 1.0, -1.0); CGContextDrawImage(spriteContext, rect, spriteImage); // 7、释放context CGContextRelease(spriteContext); return spriteData;; }
总结
根据TGA图片加载以及PNG/JPG图片加载过程的分析,我们可以简单的将图片加载的过程分为以下几步
- 1、将纹理图片解压成位图
- 2、创建
MTLTextureDescriptor
对象,即纹理描述对象,并设置pixelFormat
像素信息、width以及height
尺寸信息 - 3、使用纹理描述对象创建
MTLTexture
对象,即纹理对象 - 4、创建
MTLRegion
对象,用于标识纹理的像素区域 - 5、通过texture的
replaceRegion:mipmapLevel:withBytes:bytesPerRow:
函数将纹理图片解压的位图数据复制到纹理对象中 - 6、绘制回调函数
drawInMTKView
中,通过MTLrenderCommandEncoder
对象的setFragmentTexture:atIndex:
函数,将纹理传递到片元着色函数
完整的代码见Github :
- 20_1_Metal_加载TGA图片_OC
- 20_2_Metal_加载PNG图片_OC