iOS 8 Metal Swift教程(一) :开始学习
在本篇教程中,你将应用到3D图形中的一系列矩阵变换,并会学习到如下内容:
如何使用模型(model),视图(view)以及投影变换(projection transformations)。
如何使用矩阵运算变换几何图形
如何在着色器(shader)间传递统一数据
如何使用背面剔除(backface culling)来优化渲染
开篇
首先,你需要的下载一个工程,这和此前的教程中用到的是一样的。
构建并运行,请注意你的测试设备需要兼容Metal,然后确认你能看到下面这个三角形。
现在你需要下载一个Matrix4类,这是事先写好的,然后你需要将其加入你的工程中。这个时候因为你交叉使用了Swift和Objective-C,Xcode会提示你是否要配置一个桥接头文件(Bridging Header),这时候只要选择YES就行。
待会儿要在很多地方用到矩阵,所以你最好先看一遍Matrix4.m和Matrix4.h文件,对这个类有个清晰的认识。
iOS的内建库GLKMatrix中包含了一个用于常见3D运算的GLKMath库,其中包括可以矩阵运算的GLKMatrix4类。
本教程涉及大量的矩阵运算,使用这个库会很方便许多,不过GLKMatrix4是一个C语言结构体,所以在Swift中你不能直接调用它。
呐,所以呢,我就给各位用Objective-C封装了一下C的结构体,这样我们就能愉快的在Swift中使用GLKMatrix4了,下面是这一调用封装过程的图解:
再次提醒一下,下面内容真的会有很多矩阵运算,所以你现在还是好好看一下Matrix4类的代码吧。
重构一个节点类(Node Class)
所有的内容在一开始的工程里面的ViewController.swift文件中已经都设置好了,这的确是最简便上手的方式,不过等到你的App变得越来越大越来越复杂那可就说不定了。
在这一小节中,你需要通过以下步骤来重构项目:
1.创建顶点结构(Vertex Structure)
2.创建节点类(Node Class)
3.创建三角形子类(Triangle Subclass)
4.重构视图控制器(View Controller)
5.重构着色器(Shader)
提示:这一节是可以不看的,因为到本节最后只是让你对整个工程有个清晰的认识,如果你想要直接看3D处理的部分,那么可以直接跳过此节,而在下一节的开头你可以下载全新的开始项目——当然那是完全配好了的。
1.创建顶点结构(Vertex Structure)
新建一个文件并以 iOS/Source/Swift File为模板创建一个类,命名为Vertex.swift.
打开该文件,用下面代码覆盖它:
struct Vertex{ var x,y,z: Float // position data var r,g,b,a: Float // color data func floatBuffer() -> [Float] { return [x,y,z,r,g,b,a] } };
这个结构体会存储每一个顶点的颜色信息和位置信息。其中floatBuffer()方法会按照规定的顺序返回一个float型数组,其中包含的是结构体的位置和颜色的信息。
2.创建节点类(Node Class)
同上创建一个Swif文件命名为Node.swift.
同上步骤,代码如下:
import Foundationimport Metalimport QuartzCore class Node { let name: String var vertexCount: Int var vertexBuffer: MTLBuffer var device: MTLDevice init(name: String, vertices: Array
作为要渲染的基本单位,每个节点中的顶点都需要包含有一个名字以便使用,之后设备会为其创建buffer并渲染顶点,buffer的结构大致是这样的:
接下来,你需要把当前视图控制器中的部分渲染代码移动到Node中去,这些代码会为特定的顶点渲染起作用。
这样你需要在Node.swift里面添加一个新方法:
func render(commandQueue: MTLCommandQueue, pipelineState: MTLRenderPipelineState, drawable: CAMetalDrawable, clearColor: MTLClearColor?){ let renderPassDescriptor = MTLRenderPassDescriptor() renderPassDescriptor.colorAttachments[0].texture = drawable.texture renderPassDescriptor.colorAttachments[0].loadAction = .Clear renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColor(red: 0.0, green: 104.0/255.0, blue: 5.0/255.0, alpha: 1.0) renderPassDescriptor.colorAttachments[0].storeAction = .Store let commandBuffer = commandQueue.commandBuffer() let renderEncoderOpt = commandBuffer.renderCommandEncoderWithDescriptor(renderPassDescriptor) if let renderEncoder = renderEncoderOpt { renderEncoder.setRenderPipelineState(pipelineState) renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, atIndex: 0) renderEncoder.drawPrimitives(.Triangle, vertexStart: 0, vertexCount: vertexCount, instanceCount: vertexCount/3) renderEncoder.endEncoding() } commandBuffer.presentDrawable(drawable) commandBuffer.commit() }
在上一篇教程里面你也可以找到这段代码,你会发现这段代码来源于ViewController类的render()方法,不过对于Node的顶点渲染有所改动。
3.创建三角形子类(Triangle Subclass)
同上创建文件,命名为Triangle.swift.
代码如下:
import Foundationimport Metal class Triangle: Node { init(device: MTLDevice){ let V0 = Vertex(x: 0.0, y: 1.0, z: 0.0, r: 1.0, g: 0.0, b: 0.0, a: 1.0) let V1 = Vertex(x: -1.0, y: -1.0, z: 0.0, r: 0.0, g: 1.0, b: 0.0, a: 1.0) let V2 = Vertex(x: 1.0, y: -1.0, z: 0.0, r: 0.0, g: 0.0, b: 1.0, a: 1.0) var verticesArray = [V0,V1,V2] super.init(name: "Triangle", vertices: verticesArray, device: device) } }
这里的Triangle继承于刚刚创建的Node类,在构造函数里有三个关于三角形顶点的常量,最后打包为一个数组并传递给了父类的构造函数中。
4.重构视图控制器(View Controller)
打开ViewController.swift并删除下面这行:
var vertexBuffer: MTLBuffer! = nil
因为Node对象需要用到vertextBuffer所以这行不要了。
然后把下面这段代码:
let vertexData:[Float] = [ 0.0, 1.0, 0.0, -1.0, -1.0, 0.0, 1.0, -1.0, 0.0]
替换为:
var objectToDraw: Triangle!
之后再将这段:
// 1 let dataSize = vertexData.count * sizeofValue(vertexData[0]) // 2 vertexBuffer = device.newBufferWithBytes(vertexData, length: dataSize, options: nil)
替换为:
objectToDraw = Triangle(device: device)
好了,现在obejectToDraw初始化之后就可以用了,现在唯一还没做的事就是在ViewCotroller的render()方法中调用objetToDraw的draw()方法了。
所以最后我们要修改的代码是render()方法,将其改为下面这样:
func render() { var drawable = metalLayer.nextDrawable() objectToDraw.render(commandQueue, pipelineState: pipelineState, drawable: drawable, clearColor: nil) }
构建并运行…呃……好像可爱的三角形不见了……
这是为什么呢。这其实是因为你的顶点结构体里面现在包含了颜色数据了,而在你的顶点着色器里面仍旧还只是传递进位置信息,这就需要进行下一步。
5.重构着色器(Shader)
打开Shaders.metal然后仔细看看顶点着色器的代码,你会发现它返回的了一个float4型数据,只包括了每个顶点的位置信息。并且在参数列表里面的顶点Buffer里的数据是作为packed_float3型传入的。
接着,我们需要创建两个结构体,以便将顶点数据完整的传入着色器中,其中一个会作为着色器的返回值,这样你在看代码的时候思路会清晰得多。
在Shaders.metal中的using namespace metal下面加上如下代码:
struct VertexIn{ packed_float3 position; packed_float4 color;}; struct VertexOut{ float4 position [[position]]; //1 float4 color;};
使用VertexOut代替了原本的float4返回类型。
注意着色器必须返回位置信息,在VertexOut中,你需要使用特定的修饰符[[position]]来指明位置信息。
现在把着色器的代码改成下面这样:
vertex VertexOut basic_vertex( // 返回值修改为VertexOut类型 const device VertexIn* vertex_array [[ buffer(0) ]], // 将传入的第一个参数从float3改为VertexIn类型,注意这里的VertexIn会映射到之前创建的Vertex结构体 unsigned int vid [[ vertex_id ]]) { VertexIn VertexIn = vertex_array[vid]; // 从数组中获取顶点 VertexOut VertexOut; VertexOut.position = float4(VertexIn.position,1); VertexOut.color = VertexIn.color; // 将VertexIn传递到最后返回的VertexOut中 return VertexOut;}
所以,在这里为何不直接返回VertexIn就好了?
因为如果这么做,后面经过变换之后,整个顶点的数据就会变化——你再也找不回原来的数据了。
现在再来构建工程并运行,结果如下:
不过你还没有向着色器传进颜色值信息,现在我们来实现它。
将fragment着色器修改成这样:
fragment half4 basic_fragment(VertexOut interpolated [[stage_in]]) { //这个顶点着色器会传入VertexOut,不过它会根据你正在渲染的部分的位置信息来插入,稍后才会讨论这个 return half4(interpolated.color[0], interpolated.color[1], interpolated.color[2], interpolated.color[3]); //这样你会返回当前渲染部分的颜色值,而不是干巴巴的硬编码才有的白色 }
构建并运行你就会看到这样的结果了:
现在你可能会很奇怪为什么这个三角形变得五颜六色的,而你只定义了三个颜色值。
因为程序会根据你设定的片段自动进行差值,比如三角形的最底部里两边的顶点的距离都是一样的,这时候它的颜色值就会是左边的绿色和右边的蓝色各取50%,这一过程完全是自动的,而且对你在着色器里加上的任何颜色值都适用。
创建正方体
提示:如果你之前跳过了上一节,那么你需要下载的源码在这里。这份代码和上一节最后完成的代码一样,你可以随便看看代码有助理解。
下一步,我们将创建一个正方体来替换之前的三角形,同样的,你仍旧要把它新建为Node类的子类。
仍旧是新建一个文件并以 iOS/Source/Swift File为模板创建一个类,命名为Cube.swift.
打开这个文件,将其中内容更改为下面这样:
import UIKitimport Metal class Cube: Node { init(device: MTLDevice){ let A = Vertex(x: -1.0, y: 1.0, z: 1.0, r: 1.0, g: 0.0, b: 0.0, a: 1.0) let B = Vertex(x: -1.0, y: -1.0, z: 1.0, r: 0.0, g: 1.0, b: 0.0, a: 1.0) let C = Vertex(x: 1.0, y: -1.0, z: 1.0, r: 0.0, g: 0.0, b: 1.0, a: 1.0) let D = Vertex(x: 1.0, y: 1.0, z: 1.0, r: 0.1, g: 0.6, b: 0.4, a: 1.0) let Q = Vertex(x: -1.0, y: 1.0, z: -1.0, r: 1.0, g: 0.0, b: 0.0, a: 1.0) let R = Vertex(x: 1.0, y: 1.0, z: -1.0, r: 0.0, g: 1.0, b: 0.0, a: 1.0) let S = Vertex(x: -1.0, y: -1.0, z: -1.0, r: 0.0, g: 0.0, b: 1.0, a: 1.0) let T = Vertex(x: 1.0, y: -1.0, z: -1.0, r: 0.1, g: 0.6, b: 0.4, a: 1.0) var verticesArray:Array
是不是觉得代码有些熟悉?其实跟三角形差不多,只不过有八个顶点需要绘制。
也就是说,每一个面是由两个三角形组成的,为了便于理解,我们可以画个草图:
接下来修改ViewController.swift中的objectToDraw为Cube:
var objectToDraw: Cube!
同样的,修改构造函数,将objectToDraw初始化为Cube类型:
objectToDraw = Cube(device: device)
构建并运行之后,你会发现结果跟下面一样,不管你信不信,反正下面这个是个正方体:
你所看到的是正方体的正面——而且是非常大的特写。而且这货还按比例缩放展示了……
所以你还是不愿意相信自己看到的是个正方体是吧,还是觉得我在逗你是吧。
好的我就是在逗你,现在教你正确的方式就是修改Cube的大小,弄成下面这样:
let A = Vertex(x: -0.3, y: 0.3, z: 0.3, r: 1.0, g: 0.0, b: 0.0, a: 1.0) let B = Vertex(x: -0.3, y: -0.3, z: 0.3, r: 0.0, g: 1.0, b: 0.0, a: 1.0) let C = Vertex(x: 0.3, y: -0.3, z: 0.3, r: 0.0, g: 0.0, b: 1.0, a: 1.0) let D = Vertex(x: 0.3, y: 0.3, z: 0.3, r: 0.1, g: 0.6, b: 0.4, a: 1.0) let Q = Vertex(x: -0.3, y: 0.3, z: -0.3, r: 1.0, g: 0.0, b: 0.0, a: 1.0) let R = Vertex(x: 0.3, y: 0.3, z: -0.3, r: 0.0, g: 1.0, b: 0.0, a: 1.0) let S = Vertex(x: -0.3, y: -0.3, z: -0.3, r: 0.0, g: 0.0, b: 1.0, a: 1.0) let T = Vertex(x: 0.3, y: -0.3, z: -0.3, r: 0.1, g: 0.6, b: 0.4, a: 1.0)
构建并运行。
啊哈,正方体变小了~不过感觉有什么怪怪的地方。对了,是不是每次为了看到这个怪怪的正方体都需要修改那个可恶的顶点类呢……
当然不是,所以我们需要说一下矩阵的用法。
矩阵简介
什么是矩阵?所谓矩阵,就是矩形的数组,在3D游戏里面,你经常会看到行列都为4的4×4矩阵。
注意你使用的GLKit是以纵列为准的GLKMatrix4,所以矩阵的布局是像这样的:
通过使用矩阵你可以进行下面三种操作:
1.平移:沿着X,Y,Z轴移动。
2.旋转:绕任一坐标轴旋转。
3.缩放:沿着在任一坐标轴方向上改变大小(注意在这篇教程里面你会在所有的坐标轴上等比例进行缩放)。
那么具体怎么做呢。首先……你需要创建一个Matrix4的实例,就像下面这样(在本节你不需要添加这些代码,贴出来仅仅是为了教程说明):
var modelTransformationMatrix = Matrix4()
然后使用这些方法来变换图形:
modelTransformationMatrix.translate(positionX, y: positionY, z: positionZ)modelTransformationMatrix.rotateAroundX(rotationX, y: rotationY, z: rotationZ)modelTransformationMatrix.scale(scale, y: scale, z: scale)
关于这些东西你可以在线性代数里学到,如果能理解原理那是最好不过,但是在这篇教程里面我们并不要求。
在继续深入之前,需要打开HelloMetal-BridgingHeader.h然后导入Matrix4类,加入下面这行:
#import "Matrix4.h"
模型变换
需要用的第一个变换是模型变换,这会将你的节点坐标从本地坐标系转入世界坐标系,也就是说,你可以在广阔的范围内移动你的模型。
我们来看一下具体怎么实现,打开Node.swift然后添加下列代码:
var positionX:Float = 0.0 var positionY:Float = 0.0 var positionZ:Float = 0.0 var rotationX:Float = 0.0 var rotationY:Float = 0.0 var rotationZ:Float = 0.0 var scale:Float = 1.0
这些属性会便于你在世界坐标系中来进行位置、角度和缩放比例的设置。你还需要构建一个模型矩阵(Model Matrix)来进行稍后的矩阵变换。
在Node中加入下面这个方法:
func modelMatrix() -> Matrix4 { var matrix = Matrix4() matrix.translate(positionX, y: positionY, z: positionZ) matrix.rotateAroundX(rotationX, y: rotationY, z: rotationZ) matrix.scale(scale, y: scale, z: scale) return matrix }
在这个方法中,你会根据这些参数生成一个矩阵。
现在要做的就是把这个矩阵传递到着色器里然后用于变换,在此之前你要先弄明白统一数据(Uniform Data)这个概念。
统一数据(Uniform Data)
那么现在我们已经把不同的数据通过数组形式传递到着色器了。而模型矩阵会与整个模型进行相同的叉乘,如果为每一个顶点都拷贝一份模型矩阵来进行运算的话,在空间复杂度上会有很大消耗。
当你想要使用相同的数据与整个模型进行运算的时候,完全可以使用统一数据来完成。
首先需要把所有数据放进一个buffer对象中去,作为CPU和GPU都能访问的内存数据。
在Node.swift中,找到var vertexBuffer:MTLBuffer然后在下面加这一行:
var uniformBuffer: MTLBuffer?
接着在renderEncoder.setVertexBuffer(self.vertexBuffer, offset : 0, atIndex : 0)之后加入下面这段:
// 调用之前写的modelMatrix()方法将节点里面包括位置和角度这些值传递到一个模型矩阵里 var nodeModelMatrix = self.modelMatrix() // 向设备申请一个内存区作为Buffer并共享给CPU和GPUuniformBuffer = device.newBufferWithLength(sizeof(Float) * Matrix4.numberOfElements(), options: nil) // 生成Buffer区的初始指针(类似于OC中的void *) var bufferPointer = uniformBuffer?.contents() // 将矩阵中的数据拷贝进Buffermemcpy(bufferPointer!, nodeModelMatrix.raw(), UInt(sizeof(Float)*Matrix4.numberOfElements())) // 将uniformBuffer传递给着色器(以及所指数据),有点类似于把buffer传进特殊的顶点数据一样,只不过在这里索引atIndex的值是1而不是0renderEncoder.setVertexBuffer(self.uniformBuffer, offset: 0, atIndex: 1)
代码写到这里有一个问题:在理想情况下,你每秒大概会调用60次render()方法,也就意味着你每秒要创建60次这样的Buffer区。
连续不断的分配内存是相当奢侈的事,并且开发App的时候我们也不推崇这种做法,以后的教程里我会提供更好的解决方案(而且你也会看到iOS Metal游戏的模板),不过限于本篇教程的难度控制我们在此就先使用这种消耗颇大的方式。
现在你已经能够把矩阵传给顶点着色器了,剩下的问题就是如何使用矩阵。现在你需要在Shared.metal文件中的VetexOut结构的后面加入这个结构体:
struct Uniforms{ float4x4 modelMatrix; };
现在这个结构体仅包含了一个成员,不过之后我们会让它再包括进一个矩阵。
紧接着将下面的顶点着色器修改为这样:
vertex VertexOut basic_vertex( const device VertexIn* vertex_array [[ buffer(0) ]], const device Uniforms& uniforms [[ buffer(1) ]], //这里添加了Uniform引用类型的参数并且标记将其置于slot 1里面(和之前的代码相匹配) unsigned int vid [[ vertex_id ]]) { float4x4 mv_Matrix = uniforms.modelMatrix; //从uniforms中获取模型矩阵的数据 VertexIn VertexIn = vertex_array[vid]; VertexOut VertexOut; VertexOut.position = mv_Matrix * float4(VertexIn.position,1); //让一个顶点进行矩阵变换,只需要用这个顶点的位置矩阵和变换矩阵相乘 VertexOut.color = VertexIn.color; return VertexOut; }
到这一步之后,下面的工作就是对Cube立方体进行一些小修改了。
打开Cube.swift文件然后把正方体的值改为如下:
let A = Vertex(x: -1.0, y: 1.0, z: 1.0, r: 1.0, g: 0.0, b: 0.0, a: 1.0) let B = Vertex(x: -1.0, y: -1.0, z: 1.0, r: 0.0, g: 1.0, b: 0.0, a: 1.0) let C = Vertex(x: 1.0, y: -1.0, z: 1.0, r: 0.0, g: 0.0, b: 1.0, a: 1.0) let D = Vertex(x: 1.0, y: 1.0, z: 1.0, r: 0.1, g: 0.6, b: 0.4, a: 1.0) let Q = Vertex(x: -1.0, y: 1.0, z: -1.0, r: 1.0, g: 0.0, b: 0.0, a: 1.0) let R = Vertex(x: 1.0, y: 1.0, z: -1.0, r: 0.0, g: 1.0, b: 0.0, a: 1.0) let S = Vertex(x: -1.0, y: -1.0, z: -1.0, r: 0.0, g: 0.0, b: 1.0, a: 1.0) let T = Vertex(x: 1.0, y: -1.0, z: -1.0, r: 0.1, g: 0.6, b: 0.4, a: 1.0)
在ViewController.swift中的objectToDraw = Cube(device : device)后面加入:
objectToDraw.positionX = -0.25objectToDraw.rotationZ = Matrix4.degreesToRad(45); objectToDraw.scale = 0.5
构建并运行:
很明显之前的正方体按比例缩放了,且向左平移并绕Z轴旋转了45度。
这里至少证明了一个事:数学上矩阵比起其他什么黑客帝国的矩阵不知道要高到哪里去了。
好的,下面进入科普时间——我们可以让正方体沿着任意坐标轴平移,所以在objectToDraw.positionX = -0.25之后加入下面代码:
objectToDraw.positionY = 0.25 objectToDraw.positionZ = -0.25
构建并运行:
接着你会很困惑,你不是已经把这货往Z轴方向平移了-0.25个单位么,为什么它看上去只在X和Y轴方向有感觉但是丝毫没有像预期那样远离你呢?这不科学啊!
你可能会想,是不是矩阵传错了,不过我想说其实这里的矩阵传参没有任何问题,问题只是正方体的确移动了但是你看不出来。
如果你想知道这个问题如何解决的话,那么需要先科普一下投影矩阵(Projection Matrix)这个概念。
投影矩阵(Projection Matrix)
所谓投影变换,就是将你的视觉坐标系转换到标准坐标系,使用不同的投影变换,所得出的效果也不尽相同。
我们要介绍的是两种投影变换:正交法(Orthographic)和透视法(Perspective)。
下边的示意图中,左边的是透视法,右边是正交法,观察点位于坐标系远点。
理解透视法比较容易,因为透视就是我们平时眼睛看到事物的基本方式。正交法虽然会难一点,但是不用担心,因为你刚刚做完的“正方体”其实就是正交投影的观察结果。
想象一下你站在铁轨上,沿着铁轨看过去,你所看到的景象就是透视,如下:
而如果是在正交视角下看的话,上面这张图会变得畸形而且铁路的两侧永远都是平行的。
在下面的这张图里面,你会看到另一种投影透视法,它是一个被削尖的金字塔形,金字塔中的是你视觉感受到的实物场景,这一场景会投影到金字塔的横截面上,而这个横截面其实就是你的设备屏幕。
好了,现在我们知道Metal对所有的物体都采用了正交投影法,所以我们要做的就是将其转换为透视投影,同样的,这种转换我们也需要使用到矩阵。
为了简化整个将你的正方体放进金字塔模型中的过程。我们需要创建一个投影矩阵来描述上面说的金字塔模型,并且将其绘制到你统一的代码框架中。
Matrix4已经提供了创建透视投影矩阵的方法,所以我们只需尽情使用就好了。
在ViewController.swift中加入下面属性:
var projectionMatrix: Matrix4!
接下来在viewDidLoad()方法的最开始加入下面这段:
projectionMatrix = Matrix4.makePerspectiveViewAngle(Matrix4.degreesToRad(85.0), aspectRatio: Float(self.view.bounds.size.width / self.view.bounds.size.height), nearZ: 0.01, farZ: 100.0)
这里设置的视角为85度,不需要使用水平宽度值,因为本来你已经传入了高宽比以及视野的远端和近端的距离。
也就是说,任何比这块区域更近或者更远的物体都不会显示。
现在,修改Node.swift中的render()方法,特别说明一下,你需要添加一个额外的参数,这样render()的方法名可以改成这样:
func render(commandQueue: MTLCommandQueue, pipelineState: MTLRenderPipelineState, drawable: CAMetalDrawable, projectionMatrix: Matrix4, clearColor: MTLClearColor?)
你要添加的就是projectionMatrix参数。
然后你需要在你的uniform buffer中加入投影矩阵projectionMatrix,以便于它能传递到着色器。因为这块buffer需要传入两个矩阵的数据,所以你需要对它进行扩容:
将下面这段:
uniformBuffer = device.newBufferWithLength(sizeof(Float) * Matrix4.numberOfElements(), options: nil)
替换成:
uniformBuffer = device.newBufferWithLength(sizeof(Float) * Matrix4.numberOfElements() * 2, options: nil)
然后找到这段:
memcpy(bufferPointer!, nodeModelMatrix.raw(), UInt(sizeof(Float)*Matrix4.numberOfElements()))
修改为:
memcpy(bufferPointer! + sizeof(Float)*Matrix4.numberOfElements(), projectionMatrix.raw(), UInt(sizeof(Float)*Matrix4.numberOfElements()))
现在两个矩阵都能传入uniform buffer了。接下来你所需要做的就是在你的着色器中使用投影矩阵了。
打开Shaders.metal文件,然后按照我们之前提到过的在Uniforms中加入投影矩阵projectionMatrix:
struct Uniforms{ float4x4 modelMatrix; float4x4 projectionMatrix; };
接着在顶点着色器中,你要获取投影矩阵来进行渲染,所以要找到代码:
float4x4 mv_Matrix = uniforms.modelMatrix;
在后面添加:
float4x4 proj_Matrix = uniforms.projectionMatrix;
进行投影变换,这时候你只需要像之前做的一样将位置数据与矩阵想乘。所以把下面这段代码:
VertexOut.position = mv_Matrix * float4(VertexIn.position,1);
替换为:
VertexOut.position = proj_Matrix * mv_Matrix * float4(VertexIn.position,1);
最后,从ViewController.swift中的render()方法中传入投影矩阵。
将下面代码:
objectToDraw.render(commandQueue, pipelineState: pipelineState, drawable: drawable, clearColor: nil)
替换为:
objectToDraw.render(commandQueue, pipelineState: pipelineState, drawable: drawable,projectionMatrix: projectionMatrix, clearColor: nil)
然后将objectToDraw的变换参数改成如下:
objectToDraw.positionX = 0.0objectToDraw.positionY = 0.0objectToDraw.positionZ = -2.0objectToDraw.rotationZ = Matrix4.degreesToRad(45); objectToDraw.scale = 0.5
现在构建并运行,下面的看上去就真的很想一个正方体了,不过感觉做得还是有些不够过瘾。
我们来简要的看看刚刚都做了什么:
1.添加了一个模型矩阵,用于修改模型的位置,大小和角度。
2.添加了一个投影矩阵,将正交视角切换为正常的透视视角。
事实上前面这种超过两次以上的变换在3D渲染管线流程里面是这样实现的:
1.视图变换(View Transformation):如果你想从不同的位置观察场景中物体怎么办?修改所有场景物体的模型矩阵是可行的,不过那样做的话就太低效了。通常情况下简便的方法就是改变观察点的位置,改变视角,也就是在场景中的摄像机(camera)。
2.视口变换(Viewport Transformation):这个东西基本上就是等于在你的标准坐标系的抠出来一个小的部分作为世界然后再屏幕上绘制出来,在Metal里面这都是自动完成的——你只需要了解下就好。
下面我们要做的事情有:
1.添加视图变换。
2.让立方体旋转。
3.修改立方体的透明度。
视图变换(View Transformation)
一次视图变换就是将节点的在世界坐标系中的坐标转换到摄像机坐标系(观察者坐标系),换言之,你可以在世界坐标系中随时调整你的摄像机位置(观察点位置)。
添加视图变换相当的简单,只需要将Node.swift中的render()方法的声明改成这样:
func render(commandQueue: MTLCommandQueue, pipelineState: MTLRenderPipelineState, drawable: CAMetalDrawable, parentModelViewMatrix: Matrix4, projectionMatrix: Matrix4, clearColor: MTLClearColor?)
上面的代码添加了一个parentModelViewMatrix参数,它代表的是摄像机的位置,并将会被用来进行转换。
在render()方法中找到下面这个:
var nodeModelMatrix = self.modelMatrix()
在它后面加上:
nodeModelMatrix.multiplyLeft(parentModelViewMatrix)
请注意你无需将这个矩阵传入着色器,因为此前做那两个矩阵的时候已经传过了。你要做的是创建一个模型视图矩阵,方法是用模型矩阵和视图矩阵相乘。通常为了效率我们会预先求出他们的相乘结果。
现在打开ViewController.swift修改其中的render()方法,传进一个参数,将下面的代码:
objectToDraw.render(commandQueue, pipelineState: pipelineState, drawable: drawable,projectionMatrix: projectionMatrix, clearColor: nil)
变为这样:
var worldModelMatrix = Matrix4()worldModelMatrix.translate(0.0, y: 0.0, z: -7.0) objectToDraw.render(commandQueue, pipelineState: pipelineState, drawable: drawable, parentModelViewMatrix: worldModelMatrix, projectionMatrix: projectionMatrix ,clearColor: nil)
另外,删掉下面这些:
objectToDraw.positionX = 0.0 objectToDraw.positionY = 0.0 objectToDraw.positionZ = -2.0 objectToDraw.rotationZ = Matrix4.degreesToRad(45); objectToDraw.scale = 0.5
现在你无需将物体往后移动了,因为你的视角已经移动过了。
构建并运行:
为了加深理解,你可以进行更多的修改来测试。
仍然是修改ViewController中的render()方法,找到下面这段:
worldModelMatrix.translate(0.0, y: 0.0, z: -7.0)
然后在其后面添加:
worldModelMatrix.rotateAroundX(Matrix4.degreesToRad(25), y: 0.0, z: 0.0)
运行一下:
结果是你把物体绕X轴旋转了,或者说你的摄像机方向改变了——你高兴怎么想就怎么想吧。
旋转的立方体
现在修改代码让你的立方体能自动随着时间流逝旋转。
打开Node.swift,加入一个新属性:
var time:CFTimeInterval = 0.0
这一属性用来监测节点旋转了多长时间。
然后在节点类的最后加入这个方法:
func updateWithDelta(delta: CFTimeInterval){ time += delta }
然后打开ViewController.swift然后添加一个属性:
var lastFrameTimestamp: CFTimeInterval = 0.0
然后将下面这行:
timer = CADisplayLink(target: self, selector: Selector("gameloop"))
改成:
timer = CADisplayLink(target: self, selector: Selector("newFrame:"))
另外将这段:
func gameloop() { autoreleasepool { self.render() } }
修改为:
//每次DisplayLink链接的显示器屏幕刷新的时候都会调用newFrame方法,这里的DisplayLink是作为参数传入的 func newFrame(displayLink: CADisplayLink){ if lastFrameTimestamp == 0.0 { lastFrameTimestamp = displayLink.timestamp } // 计算当前帧与前一帧间的时间差,这个时间差并不总是一致的,因为有的帧可能会被跳过 var elapsed:CFTimeInterval = displayLink.timestamp - lastFrameTimestamp lastFrameTimestamp = displayLink.timestamp // 调用gameloop()不过将最后一次更新的时间到现在的时间差作为参数 gameloop(timeSinceLastUpdate: elapsed)} func gameloop(#timeSinceLastUpdate: CFTimeInterval) { // 在渲染前使用updateWithDelta()来更新节点信息 objectToDraw.updateWithDelta(timeSinceLastUpdate) // 当节点更新之后调用渲染render() autoreleasepool { self.render() } }
最后,需要重写一下updateWithDelta()方法,这个方法在Cube类里面,所以打开Cube.swift,然后加入该方法:
override func updateWithDelta(delta: CFTimeInterval) { super.updateWithDelta(delta) var secsPerMove: Float = 6.0 rotationY = sinf( Float(time) * 2.0 * Float(M_PI) / secsPerMove) rotationX = sinf( Float(time) * 2.0 * Float(M_PI) / secsPerMove) }
这里没什么好说的,你所做的就是调用super()来更新时间属性,然后使用正弦函数来设置正方体的旋转特性,主要就是让你的正方体转到一定角度之后又转回来。
构建并运行,现在你有一个会动的正方体了:
这样看上去有活力多了,这货反反复复的旋转就像在跳小苹果。
修改透明度
最后的一部分我们来修改正方体的透明度,不过首先你需要知道我们为什么要这么做——因为Metal默认会先绘制正方体的背面才会来绘制前面……
所以你要怎么解决这个问题呢。
让我来给你指两条明路:
1.你可以进行深度测试(Depth Testing),使用这个方法你需要存储每一个点的深度信息,这样的话当两点投影到屏幕同一点的时候,只会显示深度较低的。
2.另一方法就是背面剔除(Backface Culling),这就是说,每一个三角面片其实都只绘制了能看得见的那一面,所以事实上每一个背面的点只有当它转向摄像机的时候才会被绘制。这些都是依据你为三角面片指定顶点的顺序来决定的。
现在我们来用第二种方法也就是背面剔除来解决这个问题,当只有一个模型的时候这一方法会有效得多。唯一要坚守的原则就是:所有的三角面片必须按照逆时针方向来指定,否则就不会被绘制。
祝你幸福。我设置顶点的时候,我都对这些三角面片已经很熟悉了所以不会出错……所以你还是更关注学习如何进行背面剔除吧。
打开Node.swift然后找到这行:
if let renderEncoder = renderEncoderOpt {
在下面添加:
//For now cull mode is used instead of depth buffer renderEncoder.setCullMode(MTLCullMode.Front)
构建并运行:
现在你的正方体应该不透明了。
拓展与学习
你可以下载最终的项目文件。
现在你已经学习完有关Metal API 3D图形的不少知识了。现在对很多概念都有了大致的了解,需要消化一下。
如果兴趣足够的话,在以后的教程里面我们会讨论有关纹理、照明、以及模型导入。
下面依旧是一些参考文档:
(本文由CocoaChina翻译,如原作者更新我们将继续跟进,转载请注明出处。)