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里,这是最简单的实现方式, 但不能很好的扩展。因此这部分先分五步来重构

  1. 创建一个 Vertex 结构体
  2. 创建一个 Node
  3. 创建一个 Triangle 子类
  4. 重构 ViewController.swift
  5. 重构 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 trnasformationViewport transformation

View trnasformation如果你想在不同的位置观看场景,你可以通过修改模型变换将场景中和每个物体移动,但这比较低效。用一个单独的变换来表示你对场景的看法通常是很方便的,这就是你的“相机”。
Viewport transformation在你的世界里创建一个统一的坐标系映射到设备的屏幕上,好消息Metal已经帮我们处理好了。

View Transformation

View trnasformation将转换节点的坐标从world coordinatescamera 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有时在绘制像素先绘制背面再绘制前面,现在有两种方式可以修复。

  1. depth testing:此方法要存储每个点的深度值,当两个点画在屏幕上同一个点时,只有较低的深度会被绘制
  2. 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