写在前面:
对Metal技术感兴趣的同学,可以关注我的专题:Metal专辑
也可以关注我个人的账号:张芳涛
所有的代码存储的Github地址是:Metal
正文
可能很多人都错过了MetalKit
系列,所以今天我们又回到了它,我们将学习如何在Metal中绘制3D内容。 让我们继续在我们的playground上工作,并在系列文章的第8部分中找到我们离开的地方。
本篇博客最终是要渲染一个3D立方体,但首先让我们绘制一个2D正方形,然后我们可以为立方体的所有其他面重复使用正方形逻辑。 让我们修改vertex_data数组,使其保持4个顶点而不是3个三角形所需的顶点:
let vertex_data = [
Vertex(pos: [-1.0, -1.0, 0.0, 1.0], col: [1, 0, 0, 1]),
Vertex(pos: [ 1.0, -1.0, 0.0, 1.0], col: [0, 1, 0, 1]),
Vertex(pos: [ 1.0, 1.0, 0.0, 1.0], col: [0, 0, 1, 1]),
Vertex(pos: [-1.0, 1.0, 0.0, 1.0], col: [1, 1, 1, 1])
]
由于正方形和任何其他复杂几何体都是由三角形构成的,并且由于大多数顶点属于2个或更多个三角形,因此无需创建这些顶点的副本,因为我们有办法通过索引缓冲区重用它们来跟踪 通过存储顶点缓冲区中的每个顶点索引来使用顶点的顺序。 那么让我们创建一个索引列表:
let index_data: [UInt16] = [
0, 1, 2, 2, 3, 0
]
因此,对于正面(正方形),我们使用存储在vertex_buffer
中位置0
到3
的顶点。 稍后我们将添加其他4个顶点。 正面由两个三角形组成。 我们首先绘制使用顶点0,1和2的三角形,然后绘制使用顶点2,3和0的三角形。请注意,正如预期的那样,重复使用了两个顶点。 另请注意,绘图是顺时针完成的。 这是Metal
中默认的前向卷绕顺序,但也可以改为逆时针方向。
然后,我们需要创建index_buffer
:
var index_buffer: MTLBuffer!
接下来,我们需要将index_data
分配给createBuffers()
函数内的index buffer
(索引缓冲区):
index_buffer = device!.newBufferWithBytes(index_data, length: sizeof(UInt16) * index_data.count , options: [])
最后,在drawRect(:)
函数内部,我们需要替换drawPrimitives
调用:
command_encoder.drawPrimitives(.Triangle, vertexStart: 0, vertexCount: 3, instanceCount: 1)
使用drawIndexedPrimitives
调用:
command_encoder.drawIndexedPrimitives(.Triangle, indexCount: index_buffer.length / sizeof(UInt16), indexType: MTLIndexType.UInt16, indexBuffer: index_buffer, indexBufferOffset: 0)
代码写到这儿,在playground里面就可以看到生成的图像了
现在我们知道如何绘制一个正方形,接下来让我们看看如何绘制多个正方形:
let vertex_data = [
Vertex(pos: [-1.0, -1.0, 1.0, 1.0], col: [1, 0, 0, 1]),
Vertex(pos: [ 1.0, -1.0, 1.0, 1.0], col: [0, 1, 0, 1]),
Vertex(pos: [ 1.0, 1.0, 1.0, 1.0], col: [0, 0, 1, 1]),
Vertex(pos: [-1.0, 1.0, 1.0, 1.0], col: [1, 1, 1, 1]),
Vertex(pos: [-1.0, -1.0, -1.0, 1.0], col: [0, 0, 1, 1]),
Vertex(pos: [ 1.0, -1.0, -1.0, 1.0], col: [1, 1, 1, 1]),
Vertex(pos: [ 1.0, 1.0, -1.0, 1.0], col: [1, 0, 0, 1]),
Vertex(pos: [-1.0, 1.0, -1.0, 1.0], col: [0, 1, 0, 1])
]
let index_data: [UInt16] = [
0, 1, 2, 2, 3, 0, // front
1, 5, 6, 6, 2, 1, // right
3, 2, 6, 6, 7, 3, // top
4, 5, 1, 1, 0, 4, // bottom
4, 0, 3, 3, 7, 4, // left
7, 6, 5, 5, 4, 7, // back
]
现在我们已经准备好渲染整个立方体几何体,让我们转到MathUtils.swift
并在modelMatrix()
中注释掉旋转和转换调用,并且仅将缩放开启0.5。 您很可能会看到这样的图像:
嗯,但它仍然是一个正方形! 是的,它是一个正方形,因为我们仍然没有深度的概念,立方体看起来只是平坦的。 现在是时候调整一些数学逻辑了。 我们不再需要使用Matrix
结构,因为simd
框架为我们提供了类似的数据结构和我们可以轻松使用的数学函数。 我们可以轻松地重写变换函数以使用matrix_float4x4
而不是我们使用的自定义Matrix
结构。
但是你可能会问,3D对象如何最终出现在我们的2D屏幕上。 此过程通过一系列转换获取每个像素。 首先,modelMatrix()
将像素从对象空间转换为世界空间。 这个矩阵是我们已经知道的,负责翻译,旋转和缩放的矩阵。 使用上面重新编写的新函数,modelMatrix
可能如下所示:
func modelMatrix() -> matrix_float4x4 {
let scaled = scalingMatrix(0.5)
let rotatedY = rotationMatrix(Float(M_PI)/4, float3(0, 1, 0))
let rotatedX = rotationMatrix(Float(M_PI)/4, float3(1, 0, 0))
return matrix_multiply(matrix_multiply(rotatedX, rotatedY), scaled)
}
您会注意到之前我们无法使用的矩阵结构的有用的matrix_multiply
函数。 此外,由于所有这些像素都将经历相同的变换,我们希望将矩阵存储为Uniform
并将其传递给vertex shader
(顶点着色器)。 为了这。 让我们创建一个新结构:
struct Uniforms {
var modelViewProjectionMatrix: matrix_float4x4
}
回到createBuffers()
函数,让我们通过我们用于传递modelMatrix
的缓冲区指针将Uniforms
传递给着色器:
let modelViewProjectionMatrix = modelMatrix()
var uniforms = Uniforms(modelViewProjectionMatrix: modelViewProjectionMatrix)
memcpy(bufferPointer, &uniforms, sizeof(Uniforms))
到此为止,playground界面显示的图像应该是如下所示:
为啥是这个鬼样子?
因为像素需要经历的下一个转换是从世界空间到相机空间。 我们在屏幕上看到的所有东西都是通过虚拟相机通过截头锥体(金字塔形状)观察到的,它具有近和远的平面以限制视图(相机)空间:
回到MathUtils.swift
,我们也创建了viewMatrix()
:
func viewMatrix() -> matrix_float4x4 {
let cameraPosition = vector_float3(0, 0, -3)
return translationMatrix(cameraPosition)
}
像素需要经历的下一个变换是从相机空间到剪辑空间。 这里,不在剪辑空间内的所有顶点将确定是否将剔除三角形(剪辑空间外的所有顶点)或剪切到边界(某些顶点在外部但不是全部)。 projectionMatrix()
将帮助我们计算边界并确定顶点的位置:
func projectionMatrix(near: Float, far: Float, aspect: Float, fovy: Float) -> matrix_float4x4 {
let scaleY = 1 / tan(fovy * 0.5)
let scaleX = scaleY / aspect
let scaleZ = -(far + near) / (far - near)
let scaleW = -2 * far * near / (far - near)
let X = vector_float4(scaleX, 0, 0, 0)
let Y = vector_float4(0, scaleY, 0, 0)
let Z = vector_float4(0, 0, scaleZ, -1)
let W = vector_float4(0, 0, scaleW, 0)
return matrix_float4x4(columns:(X, Y, Z, W))
}
最后两个转换是从剪辑空间到标准化设备坐标(NDC
)以及从NDC
到屏幕空间。 这两个转换由我们的Metal
框架处理。
接下来,回到createBuffers()
函数,让我们将之前设置的modelViewProjectionMatrix
修改为modelMatrix
:
let aspect = Float(drawableSize.width / drawableSize.height)
let projMatrix = projectionMatrix(1, far: 100, aspect: aspect, fovy: 1.1)
let modelViewProjectionMatrix = matrix_multiply(projMatrix, matrix_multiply(viewMatrix(), modelMatrix()))
在drawRect(:)
中,我们需要为剔除模式和前面板设置规则,以避免奇怪的工件,如立方体透明度:
command_encoder.setFrontFacingWinding(.CounterClockwise)
command_encoder.setCullMode(.Back)
再看看生成的图像长什么样:
这终于是我们都在等着看的3D立方体! 我们还有一件事可以让它变得更加逼真和生动:给它一个旋转。 首先,让我们创建一个名为rotation
的全局变量,我们希望随着时间的推移更新:
var rotation: Float = 0
接下来,从createBuffers()
函数中获取所有矩阵,然后创建一个名为update()
的新矩阵。 这是我们每帧更新旋转以创建平滑旋转效果的位置:
func update() {
let scaled = scalingMatrix(0.5)
rotation += 1 / 100 * Float(M_PI) / 4
let rotatedY = rotationMatrix(rotation, float3(0, 1, 0))
let rotatedX = rotationMatrix(Float(M_PI) / 4, float3(1, 0, 0))
let modelMatrix = matrix_multiply(matrix_multiply(rotatedX, rotatedY), scaled)
let cameraPosition = vector_float3(0, 0, -3)
let viewMatrix = translationMatrix(cameraPosition)
let aspect = Float(drawableSize.width / drawableSize.height)
let projMatrix = projectionMatrix(0, far: 10, aspect: aspect, fovy: 1)
let modelViewProjectionMatrix = matrix_multiply(projMatrix, matrix_multiply(viewMatrix, modelMatrix))
let bufferPointer = uniform_buffer.contents()
var uniforms = Uniforms(modelViewProjectionMatrix: modelViewProjectionMatrix)
memcpy(bufferPointer, &uniforms, sizeof(Uniforms))
}
在drawRect(:)
中调用更新函数:
update()
再看看playground里面生成的图像:
搞定!