最近在学习 OpenGLES,其实也就学到纹理那里,所以想着做个小项目来巩固一下知识。而一个全景浏览器正好囊括顶点坐标,纹理坐标,索引绘图,MVP 矩阵变换等等知识,是一个很不错的练手项目。我选择用 Swift 来写,当然用 Swift 会相对麻烦一点,特别是在处理指针方面。我同时写了两种实现方式,一种是基于 iOS 封装的 GLKit,一种则是用 GLSL 来写,具体实现请查看 源码-HSGLPanoViewer。
全景浏览器实现思路
实现思路其实挺简单的,首先我们需要计算出一个球的顶点坐标(这里还包括纹理坐标和索引数组的计算),然后把全景图以纹理的形式贴在这个球上,剩下的事情都是 MVP 矩阵的魔法了。
球的顶点坐标,纹理坐标,索引数组
对于一个球,通过圆柱投影方式我们可以把它剥开成一个长方形,然后对其进行切割成一块一块的小长方形,而这些长方形的顶点则相当于组成球的坐标。
OpenGLES 里只能通过三角形来绘制,一个长方形可以以对角线分成两个三角形,而当为了便于绘制,需要有一个索引数组来表示哪几个点是组成一个三角形。
关于这块的计算我们可以参考 《OpenGL ES 3.0 Programming Guide》 里有关于球坐标的示例代码-esShapes.c,这里贴一下我用 Swift 的实现代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| private var vertices = [GLfloat]() private var indices = [GLushort]()
private func generateSphereVertices(slice: Int, radius: Float) { let parallelsNum = slice / 2 let verticesNum = (parallelsNum + 1) * (slice + 1) let indicesNum = parallelsNum * slice * 6 let angleStep = (2 * Float.pi) / Float(slice)
var vertexArray: [GLfloat] = Array(repeating: 0, count: verticesNum * 5) var vertexIndexArray: [Int] = Array(repeating: 0, count: indicesNum)
for i in 0..<(parallelsNum + 1) { for j in 0..<(slice + 1) { let vertexIndex = (i * (slice + 1) + j) * 5 vertexArray[vertexIndex + 0] = (radius * sinf(angleStep * Float(i)) * sinf(angleStep * Float(j))) vertexArray[vertexIndex + 1] = (radius * cosf(angleStep * Float(i))) vertexArray[vertexIndex + 2] = (radius * sinf(angleStep * Float(i)) * cosf(angleStep * Float(j))) vertexArray[vertexIndex + 3] = Float(j) / Float(slice) vertexArray[vertexIndex + 4] = Float(1.0) - (Float(i) / Float(parallelsNum)) } } var vertexIndexTemp = 0 for i in 0..<parallelsNum { for j in 0..<slice { vertexIndexArray[0 + vertexIndexTemp] = i * (slice + 1) + j vertexIndexArray[1 + vertexIndexTemp] = (i + 1) * (slice + 1) + j vertexIndexArray[2 + vertexIndexTemp] = (i + 1) * (slice + 1) + (j + 1) vertexIndexArray[3 + vertexIndexTemp] = i * (slice + 1) + j vertexIndexArray[4 + vertexIndexTemp] = (i + 1) * (slice + 1) + (j + 1) vertexIndexArray[5 + vertexIndexTemp] = i * (slice + 1) + (j + 1) vertexIndexTemp += 6 } } self.vertices = vertexArray self.indices = vertexIndexArray.map { GLushort($0) } }
|
MVP 矩阵
MVP 矩阵即:
- 模型矩阵(Model Matrix)
- 视图矩阵(View Matrix)
- 投影矩阵(Projection Matrix)
通过这三个矩阵,就能实现全景球、360度浏览和小行星三种视角。首先我们得知道 OpenGLES 是右手坐标系,我们面向手机屏幕,X轴正方向在右边,Y轴正方向在上边,原点在手机屏幕中心,而 Z 轴的正方向从屏幕中心指向我们自己。
全景球
全景球是最简单的,我们只需要把球体放置在坐标系原点,然后我们的视角(即相机位置),放置在 z 轴上,同时需要大于球的半径,就能看到完整的球体。
360度浏览
要想实现 360度环绕浏览,全景球还是放置在原点,把我们的视角放置在球体里面,那么我们就能 360 度环绕查看全景图了。所以我们的相机位置放在 z 轴上比球的半径小即可。
小行星
小行星的效果需要与投影矩阵配合,当然上面的两种方式也需要投影矩阵配合,只是在小行星效果这里,投影的 FOV 比前两种的大,有种贴近球体看的感觉,所以我们的摄像机视角需要放置在刚刚好球的半径上。可以想象成我们在球体挖了个小孔,眼睛往里面看的样子。
具体实现可以查看源码里的 ViewTransform.swift
经验和踩坑分享
去除 GLKit API 弃用警告
‘GLKViewController’ was deprecated in iOS 12.0: OpenGLES API deprecated. (Define GLES_SILENCE_DEPRECATION to silence these warnings)
GLKit 相关的 API 从 iOS12 之后就已经标记为弃用了。
为了避免 Xcode 满屏的黄色警告⚠️,我们在 Project--Build Settings 里找到 Preprocessor Macros ,然后配置 GLES_SILENCE_DEPRECATION=1 即可把 OpenGLES 相关的弃用 API 警告去掉。这样 Xcode 的编辑界面就清爽很多了。

注意索引数组类型
在最后让 OpenGLES 进行绘图时,都会调用 glDrawElements 方法:
1
| glDrawElements(GLenum(GL_TRIANGLES), GLsizei(vertexIndices.count), GLenum(GL_UNSIGNED_SHORT), nil)
|
这个方法的第三个参数是告诉 OpenGLES 当前索引数组的类型,比如上面指定的是 GL_UNSIGNED_SHORT 类型,所以我们的索引数组必须定义成是 [GLushort] 即数组里面的元素是 UInt16 ,我们可以点击 GLushort 的定义是 public typealias GLushort = UInt16)
这里我当初很傻地犯了一个错误是:我点击 GL_UNSIGNED_SHORT 进去查看它的定义是 public var GL_UNSIGNED_SHORT: Int32 { get } ,然后下意识认为索引数组的元素类型是 Int32 ,结果导致最后效果怎么都不对,最后才发现是这里被绕晕了。
数组所占内存大小
OpenGLES 里有些方法是需要传递数值所占的内存大小。比如:
1 2
| glBufferData(GLenum(GL_ARRAY_BUFFER), vertices.size(), vertices, GLenum(GL_STATIC_DRAW))
|
这里我给 Array 添加了一个扩展方法,能够比较方便地获取到数组实际所占内存的大小:
1 2 3 4 5 6
| extension Array { public func size() -> Int { return MemoryLayout<Element>.stride * self.count } }
|
矩阵的构建
矩阵的构建和计算是一个较为复杂的部分,所以最好是把它交给程序。
GLKit 中提供了不少便捷的类和方法,比如
GLKMatrix4MakeLookAt :构建摄像机视图矩阵
GLKMatrix4MakePerspective : 构建投影视图矩阵
GLKMatrix4Multiply : 用于矩阵的相乘
GLKMatrix4RotateX : 绕 x 轴旋转的旋转矩阵
……
利用这些方法在 GLKit 的实现中很方便,但是如何扩展利用在 GLSL 的实现呢?这里贴一下更新 MVP 矩阵的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| private func updateMVPMatrix() { var modelViewMatrix = GLKMatrix4Identity modelViewMatrix = GLKMatrix4RotateX(modelViewMatrix, xAxisRotate) modelViewMatrix = GLKMatrix4RotateY(modelViewMatrix, yAxisRotate) modelViewMatrix = GLKMatrix4Multiply(panoViewType.viewTransform.viewMatrix, modelViewMatrix) let width = frame.size.width * UIScreen.main.scale let height = frame.size.height * UIScreen.main.scale let aspect = GLfloat(width / height) let projectionMatrix = panoViewType.viewTransform.projectionMatrix(aspect: aspect) var mvpMatrix = GLKMatrix4Multiply(projectionMatrix, modelViewMatrix) let components = MemoryLayout.size(ofValue: mvpMatrix.m) / MemoryLayout.size(ofValue: mvpMatrix.m.0) withUnsafePointer(to: &mvpMatrix.m) { $0.withMemoryRebound(to: GLfloat.self, capacity: components) { glUniformMatrix4fv(glGetUniformLocation(shaderProgram, "mvpMatrix"), 1, GLboolean(GL_FALSE), $0) } } }
|
从上面的代码可以看到,我们通过 GLKMatrix4 的 m 属性拿到矩阵的数组数据,然后用 withUnsafePointer 的方式拿到数组的指针,以完成 glUniformMatrix4fv 的调用。
参考资料
欢迎关注我的公众号:HansonTalk
