Metal 练习:第二篇-3D
Metal 练习:第二篇-3D
此篇练习是基于前一篇 的拓展
此篇练习完成后,将会学到如何设置一系列的矩阵变换来移动到3D,过程中还会学到:
- 如何使用model、view、projection transformations
- 如何使用矩阵实现几何变换
- 如何传递统一的数据到shaders
- 如何使用backface culling 优化绘图
正题开始
打开项目,本部分内容将会用到大量的矩阵,现有一个OC的矩阵封装文件,后面计算会用到,如下
// Swift 在 10.0以后有对矩阵计算的支持
// Matrix4.h
#import
#import
#import
@interface Matrix4 : NSObject{
@public
GLKMatrix4 glkMatrix;
}
+ (Matrix4 * _Nonnull)makePerspectiveViewAngle:(float)angleRad
aspectRatio:(float)aspect
nearZ:(float)nearZ
farZ:(float)farZ;
- (_Nonnull instancetype)init;
- (_Nonnull instancetype)copy;
- (void)scale:(float)x y:(float)y z:(float)z;
- (void)rotateAroundX:(float)xAngleRad y:(float)yAngleRad z:(float)zAngleRad;
- (void)translate:(float)x y:(float)y z:(float)z;
- (void)multiplyLeft:(Matrix4 * _Nonnull)matrix;
- (void * _Nonnull)raw;
- (void)transpose;
+ (float)degreesToRad:(float)degrees;
+ (NSInteger)numberOfElements;
@end
// Matrix4.m
#import "Matrix4.h"
@implementation Matrix4
#pragma mark - Matrix creation
+ (Matrix4 *)makePerspectiveViewAngle:(float)angleRad
aspectRatio:(float)aspect
nearZ:(float)nearZ
farZ:(float)farZ{
Matrix4 *matrix = [[Matrix4 alloc] init];
matrix->glkMatrix = GLKMatrix4MakePerspective(angleRad, aspect, nearZ, farZ);
return matrix;
}
- (instancetype)init{
self = [super init];
if(self != nil){
glkMatrix = GLKMatrix4Identity;
}
return self;
}
- (instancetype)copy{
Matrix4 *mCopy = [[Matrix4 alloc] init];
mCopy->glkMatrix = self->glkMatrix;
return mCopy;
}
#pragma mark - Matrix transformation
- (void)scale:(float)x y:(float)y z:(float)z{
glkMatrix = GLKMatrix4Scale(glkMatrix, x, y, z);
}
- (void)rotateAroundX:(float)xAngleRad y:(float)yAngleRad z:(float)zAngleRad{
glkMatrix = GLKMatrix4Rotate(glkMatrix, xAngleRad, 1, 0, 0);
glkMatrix = GLKMatrix4Rotate(glkMatrix, yAngleRad, 0, 1, 0);
glkMatrix = GLKMatrix4Rotate(glkMatrix, zAngleRad, 0, 0, 1);
}
- (void)translate:(float)x y:(float)y z:(float)z{
glkMatrix = GLKMatrix4Translate(glkMatrix, x, y, z);
}
- (void)multiplyLeft:(Matrix4 *)matrix{
glkMatrix = GLKMatrix4Multiply(matrix->glkMatrix, glkMatrix);
}
#pragma mark - Helping methods
- (void *)raw{
return glkMatrix.m;
}
- (void)transpose{
glkMatrix = GLKMatrix4Transpose(glkMatrix);
}
+ (float)degreesToRad:(float)degrees{
return GLKMathDegreesToRadians(degrees);
}
+ (NSInteger)numberOfElements{
return 16;
}
@end
重构成一个Node
类
在前一篇中所有的设置都放在
ViewController.swift
里,这是最简单的实现方式, 但不能很好的扩展。因此这部分先分五步来重构
- 创建一个
Vertex
结构体- 创建一个
Node
类- 创建一个
Triangle
子类- 重构
ViewController.swift
- 重构
Shaders.metal
1. 创建一个 Vertex
结构体
// 在你的工程中新创建一个 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]
}
}
Vertex用来存储位置和颜色,
floatBuffer()
按照严格的顺序快捷反回顶点数据数组
2. 创建一个 Node
类
// 在你的工程中新创建一个 Node.swift 文件,并添加如下代码
import Foundation
import Metal
class Node {
let device: MTLDevice
let name: String
var vertexCount: Int
var vertexBuffer: MTLBuffer
init(name: String, vertices: Array, device: MTLDevice){
// 1. 遍历顶点数组,转换成[x,y,z,r,g,b,a, x,y,z,r,g,b,a, x,y,z,r,g,b,a, ...]这样的[Float]数组
var vertexData = Array()
for vertex in vertices{
vertexData += vertex.floatBuffer()
}
// 2. 要求device使用上面的 [Float]数组创建一个顶点缓冲区
let dataSize = vertexData.count * MemoryLayout.size(ofValue: vertexData[0])
vertexBuffer = device.makeBuffer(bytes: vertexData, length: dataSize, options: [])!
// 3. 初始化成员变量
self.name = name
self.device = device
vertexCount = vertices.count
}
}
Node
代表一个要绘制的对象,要包含顶点、名字(用来区分的)、device(用来创建缓冲区和后面的渲染),然后需要将ViewController.swift
中的render()
代码移到Node.swift
中。
// 将以下代码添加到 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.makeCommandBuffer()
let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor)
renderEncoder.setRenderPipelineState(pipelineState)
renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, at: 0)
renderEncoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: vertexCount,
instanceCount: vertexCount/3)
renderEncoder.endEncoding()
commandBuffer.present(drawable)
commandBuffer.commit()
}
3. 创建一个 Triangle
子类
// 创建一个 Triangle.swift 文件,添加一个 Triangle 类,继承 Node
import Foundation
import 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)
let verticesArray = [V0,V1,V2]
super.init(name: "Triangle", vertices: verticesArray, device: device)
}
}
在初始化方法中直接定义组成三角形的三个顶点,并将数据传给父类初始化方法中。
4. 重构 ViewController.swift
删除
ViewController.swift
中下面代码,因为Node
持有了顶点数据,此处不再需要
var vertexBuffer: MTLBuffer!
以下代码
let vertexData:[Float] = [
0.0, 1.0, 0.0,
-1.0, -1.0, 0.0,
1.0, -1.0, 0.0]
用下面代替
var objectToDraw: Triangle!
以下代码
let dataSize = vertexData.count * sizeofValue(vertexData[0])
vertexBuffer = device.newBufferWithBytes(vertexData, length: dataSize, options: nil)
用下面代替
objectToDraw = Triangle(device: device)
objectDraw
初始化完成,ViewController
中的render()
方法改成如下代码
func render() {
guard let drawable = metalLayer?.nextDrawable() else { return }
objectToDraw.render(commandQueue: commandQueue, pipelineState: pipelineState, drawable: drawable, clearColor: nil)
}
5. 重构 Shaders.metal
顶点着色器从顶点缓冲中取出数据,类型为
packed_float3
,包装成float4
直接返回。接下来要创建两个结构体来持有,一个传递给顶点着色器, 一个作为顶点着色器的返回值(VertexOut
代替float4
),
// 以下代码直接添加在 `using namespace metal;` 下面
struct VertexIn{
packed_float3 position;
packed_float4 color;
};
struct VertexOut{
float4 position [[position]]; //1
float4 color;
};
修改
Shaders.metal
中顶点着色器的代码
vertex VertexOut basic_vertex( // 1
const device VertexIn* vertex_array [[ buffer(0) ]], // 2
unsigned int vid [[ vertex_id ]]) {
VertexIn VertexIn = vertex_array[vid]; // 3
VertexOut VertexOut;
VertexOut.position = float4(VertexIn.position,1);
VertexOut.color = VertexIn.color; // 4
return VertexOut;
}
// 1. 标记顶点着色器的返回类型用VertexOut代替float4
// 2. 用Vextex代替packed_float2标记vertex_array,而且VertexIn与Vertex类相对应
// 3. 从数组中获取当前顶点
// 4. 创建一个VertexOut,从VextexIn传递数据到VertexOut
上面改变了顶点着色器的部分,而颜色仍然是白色,下面改造颜色着色器部分。
fragment half4 basic_fragment(VertexOut interpolated [[stage_in]]) { //1
return half4(interpolated.color[0], interpolated.color[1], interpolated.color[2], interpolated.color[3]); //2
}
// 1. 顶点着色器传递VertexOut给片段着色器,但它的值会根据片段渲染的位置进行插值
// 2. 返回当前片段的颜色,而不直接写死
到这里你可以运行你的工程看看效果
创建一个立方体
上面已经创建了一个平面图形三角形,与所有对象模型一样,现在只需创建一个
Node
子类就可以。创建一个Cubic.swift
文件,然后添加如下代码
import Foundation
import 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)
let verticesArray:Array = [
A,B,C ,A,C,D, //Front
R,T,S ,Q,R,S, //Back
Q,S,B ,Q,B,A, //Left
D,C,T ,D,T,R, //Right
Q,A,D ,Q,D,R, //Top
B,S,T ,B,T,C //Bot
]
super.init(name: "Cube", vertices: verticesArray, device: device)
}
}
ViewController.swift
中做如下修改属性
objectToDraw
类型改为Cubic!
objectToDraw = Triangle(device: device)
改为objectToDraw = Cube(device: device)
好像看不出什么立体的效果,但事实上确实是一个立方体。你现在看到的只是前面(从透视图层面来说),如果要改变大小,你可以用以下代码替换对应顶点的值
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)
改完后可以运行看下,是不是不不一样了,但这样改数据很麻烦。那接下就是用矩阵的时候了。下图就是一个4x4的矩阵
通过矩阵可以完成很多操作,如平移、旋转、缩放,如下图
如何使用矩阵?首先在桥接文件中添加我们开始入的矩阵的头文件Matrix4.h
,然后在Node
类中增加以下属性和方法,属性方便设值、 方法就是根据属性返回一个矩阵
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
func modelMatrix() -> Matrix4 {
let 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
目前为止,通过顶点数组为着色器中顶点传递不同的数据,但是模型生命周期中模型矩阵一直是不变的,这将浪费很多空间来为每个顶点copy数据。 当在模型的整个过程是相同的数据时,传递给着色器的数据可以以
uniform data
的形式。第一步:将数据装进CPU和GPU都可访问的缓存对象。在代码
renderEncoder.setVertexBuffer(self.vertexBuffer, offset: 0, atIndex: 0):
下面加入如下代码
// 1. 获取一个模型的矩阵
let nodeModelMatrix = self.modelMatrix()
// 2. 要求device创建一个CPU/GPU内存共享的缓存
let uniformBuffer = device.makeBuffer(length: MemoryLayout.size * Matrix4.numberOfElements(), options: [])
// 3. 定义一个指向缓存的指针(类似OC中的 `Void *`)
let bufferPointer = uniformBuffer.contents()
// 4. 拷贝数据到缓存中
memcpy(bufferPointer, nodeModelMatrix.raw(), MemoryLayout.size * Matrix4.numberOfElements())
// 5. 像传递缓存给顶点一样,将 `uniformBuffer`传递给着色器,只是此处 `index` 为 1
renderEncoder.setVertexBuffer(uniformBuffer, offset: 0, at: 1)
?????? 上面的代码有个问题:
render()
方法每秒会调用60次,这就意味着每秒会创建60个新的缓存。连续为每一帧分配内存是非常浪费的,后面会优化
上面的已经实现将矩阵传递给了顶点着色器,但着色内部还没有用上,因此要在
Shaders.metal
中添加代码
// 在 VertexOut 结构体下面添加
struct Uniforms {
float4x4 modelMatrix;
};
// 然后将顶点着色器修改如下
vertex VertexOut basic_vertex(
const device VertexIn* vertex_array [[ buffer(0) ]],
const device Uniforms& uniforms [[ buffer(1) ]], //1
unsigned int vid [[ vertex_id ]]) {
float4x4 mv_Matrix = uniforms.modelMatrix; //2
VertexIn VertexIn = vertex_array[vid];
VertexOut VertexOut;
VertexOut.position = mv_Matrix * float4(VertexIn.position,1); //3
VertexOut.color = VertexIn.color;
return VertexOut;
}
// 1. 知道前面的 index 为啥是1不是0。为前面的 uniform buffer 添加一个接收参数
// 2. 得到 Uniforms 结构体模型矩阵
// 3. 对顶点应用模型变换,只需乘以顶点位置的矩阵就行
// 为了更多的效果,在 objectToDraw = Cubic(device: device) 下面加上这几句代码
objectToDraw.positionX = -0.25
objectToDraw.rotationZ = Matrix4.degrees(toRad: 45)
objectToDraw.scale = 0.5
如果你在前面将ABCDQRST点改为了0.3,你可以改回到1.0,然后运行项目。
投射变换
下图将有助于理解透视图(左)和正交图(右)的区别,然而
Metal
渲染所有的都是正交投射,与人眼看到的(透视图)习惯不同,因此要转换场景。 因此要调用一个矩阵来描述透视图变换。
// 在 ViewController.swift 中添加一个属性
var projectionMatrix: Matrix4!
// 在 viewDidLoad() 方法最上面添加以下代码
projectionMatrix = Matrix4.makePerspectiveViewAngle(Matrix4.degrees(toRad: 85.0), aspectRatio: Float(self.view.bounds.size.width / self.view.bounds.size.height), nearZ: 0.01, farZ: 100.0)
// 修改 Node.swift 中 render() 方法,添加一个参数 projectionMatrix: Matrix4
func render(commandQueue: MTLCommandQueue, pipelineState: MTLRenderPipelineState, drawable: CAMetalDrawable, projectionMatrix: Matrix4, clearColor: MTLClearColor?) {
projectionMatrix
也要传递给顶点着色器,uniformBuffer
中就要包含两个矩阵,所以要增加缓存大小
// 将这句
let uniformBuffer = device.makeBuffer(length: MemoryLayout.size * Matrix4.numberOfElements(), options: [])
// 改成下面这句
let uniformBuffer = device.makeBuffer(length: MemoryLayout.size * Matrix4.numberOfElements() * 2, options: [])
// 然后在 `memcpy(bufferPointer, nodeModelMatrix.raw()...... `下面加上下面这句
memcpy(bufferPointer + MemoryLayout.size * Matrix4.numberOfElements(), projectionMatrix.raw(), MemoryLayout.size * Matrix4.numberOfElements())
缓存中已经加上了投射变换矩阵,此时要修改
Shaders.metal
文件来接收
// Uniforms要添加一个matrix
struct Uniforms {
float4x4 modelMatrix;
float4x4 projectionMatrix;
};
// 在顶点着色中 `float4x4 mv_Matrix = uniforms.modelMatrix;` 下面添加一句
float4x4 mv_Matrix = uniforms.modelMatrix;
// 然后在这个方法的最后,修改 VertexOut.position 的赋值
VertexOut.position = proj_Matrix * mv_Matrix * float4(VertexIn.position,1);
最后回到
ViewController.swift
中将objectToDraw.render
调用换成如下代码
objectToDraw.render(commandQueue: commandQueue, pipelineState: pipelineState, drawable: drawable, projectionMatrix: projectionMatrix, clearColor: nil)
现在你可以运行工程,会得到一个看起来有点像正方体的图形,但还不是很明显
改善上面情况,引入两个在3D管线中常用的变换,
View trnasformation
和Viewport transformation
View trnasformation
如果你想在不同的位置观看场景,你可以通过修改模型变换将场景中和每个物体移动,但这比较低效。用一个单独的变换来表示你对场景的看法通常是很方便的,这就是你的“相机”。
Viewport transformation
在你的世界里创建一个统一的坐标系映射到设备的屏幕上,好消息Metal
已经帮我们处理好了。
View Transformation
View trnasformation
将转换节点的坐标从world coordinates
到camera coordinates
,也就是允许在你的坐标系中随便移动你的相机。 添加一个View trnasformation
是很简单的,在Node.swift
中改变render()
方法的声明,像下面这样
// parentModelViewMatrix: 代表相机位置,将用于场景转换
func render(commandQueue: MTLCommandQueue, pipelineState: MTLRenderPipelineState, drawable: CAMetalDrawable, parentModelViewMatrix: Matrix4, projectionMatrix: Matrix4, clearColor: MTLClearColor?) {
// 在 render() 方法中,此句 let nodeModelMatrix = self.modelMatrix() 下面添加一句
nodeModelMatrix.multiplyLeft(parentModelViewMatrix)
在
ViewController.swift
中找到objectToDraw.render
调用,修改成为如下代码
let worldModelMatrix = Matrix4()
worldModelMatrix.translate(0.0, y: 0.0, z: -7.0)
worldModelMatrix.rotateAroundX(Matrix4.degrees(toRad: 25), y: 0.0, z: 0.0) // 以X轴旋转一个角度
objectToDraw.render(commandQueue: commandQueue, pipelineState: pipelineState, drawable: drawable, parentModelViewMatrix: worldModelMatrix, projectionMatrix: projectionMatrix ,clearColor: nil)
如果想正方体跟随时间转动,可以做如下修改
// 在 Node.swift添加一个属性
var time: CFTimeInterval = 0.0
// 同时在类的最后添加一个方法
func updateWithDelta(delta: CFTimeInterval){
time += delta
}
// 在 ViewController.swift中添加一个属性
var lastFrameTimestamp: CFTimeInterval = 0.0
// 同时替换timer的赋值
timer = CADisplayLink(target: self, selector: #selector(ViewController.newFrame(displayLink:)))
// 同时替换 gameloop 方法
// 1. 定时器响应的方法
func newFrame(displayLink: CADisplayLink){
if lastFrameTimestamp == 0.0
{
lastFrameTimestamp = displayLink.timestamp
}
// 2. 当前帧与上一帧的间隔
let elapsed: CFTimeInterval = displayLink.timestamp - lastFrameTimestamp
lastFrameTimestamp = displayLink.timestamp
// 3. 自上次更新后的时间间隔再调用
gameloop(timeSinceLastUpdate: elapsed)
}
func gameloop(timeSinceLastUpdate: CFTimeInterval) {
// 4. 在渲染关更新节点
objectToDraw.updateWithDelta(delta: timeSinceLastUpdate)
// 5. 渲染
autoreleasepool {
self.render()
}
}
// 在 `Cubic` 类中添加下面代码
override func updateWithDelta(delta: CFTimeInterval) {
super.updateWithDelta(delta: delta)
let secsPerMove: Float = 6.0
rotationY = sinf( Float(time) * 2.0 * Float(Double.pi) / secsPerMove)
rotationX = sinf( Float(time) * 2.0 * Float(Double.pi) / secsPerMove)
}
// x,y的位置跟随sin函数周期变动
修复透明度问题
Metal
有时在绘制像素先绘制背面再绘制前面,现在有两种方式可以修复。
depth testing
:此方法要存储每个点的深度值,当两个点画在屏幕上同一个点时,只有较低的深度会被绘制backface culling
:此方法表示每绘制的一个三角形只会从一侧看到。实际上背面直到转到前面才会被绘制,这是基于指定三角形顶点的顺序。此方法有一点要遵守:所有的三角形必须逆时针绘制,否则不会被渲染。
// 采用 backface culling 方法
// 在 Node.swift的render()里 let renderEncoder = commandBuffer.makeRenderCommandEncoder.... 下面加上这句
renderEncoder.setCullMode(MTLCullMode.front)
参考及更多资料
- 原文:Metal Tutorial with Swift 3 Part 2: Moving to 3D
- Apple’s Metal for Developers page
- Apple’s Metal Programming Guide
- Apple’s Metal Shading Language Guide
- WWDC2014 For Metal