在 Cocoa 中使用 Swift 3.0 创建 OpenGL 程序

发布于:2016-10-02 00:17,阅读数:3590,点赞数:9


> 本项目为使用Swift 3.0编写的OpenGL Demo。项目在[「GitHub」](https://github.com/trmbhs/EMMacOpenGLDemo)中开源。

# 导语

本文适合有`OpenGL基础`和`Swift语言基础`以及曾在 C/C++ 中开发过`OpenGL`和`OpenGL SL`程序的读者。Demo将在 Cocoa 中使用 Swift 语言创建一个简单的 OpenGL 程序。

# Demo效果
![](//cdn.yuusann.com/img/posts/16003_1.jpg)
![](//cdn.yuusann.com/img/posts/16003_2.jpg)
![](//cdn.yuusann.com/img/posts/16003_3.jpg)

# 开发环境

macOS 10.12 + Xcode 8.0。

# StoryBoard

Storyboard中存在一个`NSOpenGLView`控件,它会为我们创建好了OpenGL上下文(`Context`),可以直接使用。我们的所有OpenGL代码都依赖着这个上下文执行。在界面方面直接将其加入到界面中,附上全屏的AutoLayout,界面就设计完毕了。

![](//cdn.yuusann.com/img/posts/16003_4.jpg)

使用`glGetString(GLenum(GL_VERSION))`可以看到自动创建的上下文版本为`OpenGL 2.1`,这个版本是支持`Shader`的,因此后面我们会用到`OpenGL SL`语言来处理图元和顶点。

# 对象结构

在我设计的封装中,一个完整的OpenGL程序需要有:

- 窗口(`View`):直接显示在屏幕中,其继承于`NSOpenGLView`,与Cocoa交互。
- 摄像机(`Camera`):摄像机是观察的媒介。不同位置、方向、不同观察模式的摄像机将会呈现不同的视觉效果。因此我们的OpenGL程序包括`Shader`程序主要存在于摄像机中,不同的摄像机可以以不同的渲染程序去观察场景中的物体。
- 场景(`Scene`):场景中仅仅统一了物体,维护当前场景中的物体供摄像机调用。
- 物体(`Object`):物体是主要的观察对象。包含了大小、方向、位置、贴图等等信息。

在源码中的`GLObject`目录中可以看到我封装的一些基类,读者在开发自己的OpenGL程序时可以以这些类作为基类来继承出自己的子类。

### GLView

继承于`NSOpenGLView`,重写了`draw`方法(Swift 3.0以前为`drawRect`),当View大小改变时Cocoa会回调`draw`方法,此时我们可以通知OpenGL程序窗口的大小改变了。

### GLScene

提供了物体的容器以及方法,创建子类后可以在子类创建物体。

### GLCamera

OpenGL渲染程序写在这个类的子类中。需要重写的方法有:

```swift
override func glInit()
override func glResize(width: CGFloat, height: CGFloat)
override func glDraw()
override func glDeinit()
```

其中`glDraw()`方法将会在被`CVDisplayLink`中每秒60次调用,其具体实现在`GLExtension`中,将在下文讨论。最主要的OpenGL程序将在上面四个方法中实现。

在执行绘制的OpenGL代码前,我们必须获得需要显示的`NSOpenGLView`所创建的上下文,并设置为当前上下文:

```swift
weak var view: GLView!

let context = view.openGLContext!
context.makeCurrentContext()
```

OpenGL代码可能会同时执行在多个线程里。例如常规的绘制函数由`CVDisplayLink`驱动,此时用户改变了窗口的大小,那么`GLView`会通知OpenGL执行窗口变换的代码,此时将会发生两个线程同时操作同一个上下文的情况,会导致程序崩溃。在这里为了安全起见,执行前需加锁,之行结束后解锁:

```swift
CGLLockContext(context.cglContextObj!)
//Draw...
CGLUnlockContext(context.cglContextObj!)
```

### GLObject
物体基类。包含物体的大小、方向、位置数据。另外`GLObject`遵守`GLDrawable`协议,需实现`draw`方法,供绘制时调用。需要重写方法:

```swift
override func draw()
```

### GLSquare

封装好的方形平面。可以直接传入坐标和贴图配置绘制出来。其具体实现在`GLExtension`中,将在下文讨论。

### GLCube

封装好的长方体。可以直接传入坐标和贴图配置绘制出来。其具体实现在`GLExtension`中,将在下文讨论。

### GLTexConfig

贴图配置的封装,其构造方法原型为:

```swift
init(type: GLTexType, texID: GLuint? = nil,
texWidthRepeat: Float? = 1, texHeightRepeat: Float? = 1,
width: GLfloat? = nil, height: GLfloat? = nil, yuvIDs: Array<GLuint>? = nil, yuvData: NSData? = nil,
alpha: Float = 1)
```

- type :贴图图像类型,可以是`RGB`、`YUV`、`OES扩展`等,Demo中仅实现了RGB贴图。其余贴图的绘制可以直接在源码中扩展,直接实现相应方法即可。
- texID:贴图在OpenGL中的槽位。
- texWidthRepeat:贴图在横向的重复次数。由于封装的贴图函数的`GL_TEXTURE_WRAP_S`参数为`GL_REPEAT`,而贴图坐标映射是在封装中自动计算的,因此这里可以指定重复次数,默认为1。
- texHeightRepeat:贴图在纵向的重复次数,同上。
- width, height, yuvIDs, yuvData:贴图长宽、YUV贴图的ID数组(yuv贴图如果采用多重纹理,则ID数组至少有3个元素)、YUV数据。这些在绘制YUV数据时才需要指定。YUV贴图在Demo中没有实现具体方法,但预留了实现的空间,读者可以自己扩展。YUV转RGB多重纹理的`Shader`函数在Demo中已经给出。
- alpha:贴图透明度。在每个`GLObject`的绘制函数中均会将贴图配置传递至`GLExtension`进行处理。

# 扩展

Swift 语言对于`指针`的支持十分不友好,因此在OpenGL接口和Swift程序之间封装中间层是必要的。

### GLExtension

`GLExtension`是我封装的一系列类方法的集合。它是整个OpenGL程序的核心,是它驱动着整个OpenGL程序运行。

![](//cdn.yuusann.com/img/posts/16003_5.jpg)

OpenGL绘制程序的驱动依赖`CVDisplayLink`,它会在每次显示器刷新时调用绘制函数,使OpenGL程序与显示器刷新率保持同样帧率。`CVDisplayLink`存在于`CoreVideo`中,其具体实现代码为:

```swift
import CoreVideo

static var displayLink: CVDisplayLink?
static func gleRun(sender: GLCamera) {
CVDisplayLinkCreateWithActiveCGDisplays(&displayLink)
CVDisplayLinkSetOutputHandler(displayLink!) { (_, _, _, _, _) -> CVReturn in
sender._glDraw()
return kCVReturnSuccess
}
CVDisplayLinkStart(displayLink!)
}

static func gleStop() {
CVDisplayLinkStop(displayLink!)
}
```

`CVDisplayLink `接口为非常典型的`C`接口,`CVDisplayLinkSetOutputCallback`接收一个C函数指针(`@convention(c)`)来处理回调,也可以使用`CVDisplayLinkSetOutputHandle`接口,传入一个闭包来处理回调。

在处理贴图上使用`CGImage`在`CGContext`上`draw`的方法来取得图像数据指针交至`glTexImage2D`接口处理贴图:

```swift
static func gleUpdateRGBTexture(id: GLuint, image: CGImage) {
let width = image.width
let height = image.height
let dataSize = width * height * 4
let data = UnsafeMutablePointer<UInt8>.allocate(capacity: dataSize)
let colorSpace = image.colorSpace!
let context = CGContext.init(data: data, width: width, height: height, bitsPerComponent: 8, bytesPerRow: width * 4, space: colorSpace, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!
context.draw(image, in: CGRect.init(x: 0, y: 0, width: CGFloat(width), height: CGFloat(height)))
glBindTexture(GLenum(GL_TEXTURE_2D), id)
glTexImage2D(GLenum(GL_TEXTURE_2D), 0, GL_RGBA, GLsizei(width), GLsizei(height), 0, GLenum(GL_RGBA), GLenum(GL_UNSIGNED_BYTE), data)
glBindTexture(GLenum(GL_TEXTURE_2D), 0)
free(data)
}
```

在`CGImage`释放池的处理上使用`autoreleasepool{ }`来及时释放`CGImage`占用的内存,因为在OpenGL程序里在内存中缓存`CGImage`数据是没有意义的,创建纹理时数据就已经交给显存了。修改后的贴图处理程序为:

```swift
static func gleUpdateRGBTexture(id: GLuint, image: CGImage) {
let width = image.width
let height = image.height
let dataSize = width * height * 4
let data = UnsafeMutablePointer<UInt8>.allocate(capacity: dataSize)
autoreleasepool {
let colorSpace = image.colorSpace!
let context = CGContext.init(data: data, width: width, height: height, bitsPerComponent: 8, bytesPerRow: width * 4, space: colorSpace, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!
context.draw(image, in: CGRect.init(x: 0, y: 0, width: CGFloat(width), height: CGFloat(height)))
glBindTexture(GLenum(GL_TEXTURE_2D), id)
glTexImage2D(GLenum(GL_TEXTURE_2D), 0, GL_RGBA, GLsizei(width), GLsizei(height), 0, GLenum(GL_RGBA), GLenum(GL_UNSIGNED_BYTE), data)
glBindTexture(GLenum(GL_TEXTURE_2D), 0)
}
free(data)
}
```

着色器(`Shader`)程序的创建也在这里做了封装,以下两个方法可以创建并获得着色器程序在OpenGL中的槽位备用。

```swift
static func gleBuildShader(sourceFilename: String, type: String = default, shaderType: GLenum) -> GLuint
static func gleBuildProgram(vertexHandle: GLuint, fragmentHandle: GLuint) -> GLuint
```

创建`Shader`时`glShaderSource `接口接收一个`UnsafePointer<UnsafePointer<GLchar>?>!`类型指针来获得`Shader`程序数据。在 Swift 中获取这样的一个指针又是一件麻烦的事情。解决方案为多次暴露指针和强转:

```swift
//读取文本
let sourcePath = Bundle.main.path(forResource: sourceFilename, ofType: type)!
let sourceData = NSData.init(contentsOfFile: sourcePath)!
//获取长度
var dataSize: GLint = GLint(sourceData.length)
//创建Byte数组和各项指针
let dataBytes = UnsafeMutableRawPointer.allocate(bytes: Int(dataSize), alignedTo: 0)
var sourcePtr = unsafeBitCast(dataBytes, to: UnsafePointer<GLchar>.self)
var sourcePtrPtr: UnsafePointer<UnsafePointer<GLchar>?>!
withUnsafePointer(to: &sourcePtr, { ptr in
sourcePtrPtr = unsafeBitCast(ptr, to: UnsafePointer<UnsafePointer<GLchar>?>.self)
})
//填充
sourceData.getBytes(dataBytes, range: NSRange.init(location: 0, length: Int(dataSize)))
glShaderSource(shaderHandle, 1, sourcePtrPtr, dataSize.pointer)
free(dataBytes)
glCompileShader(shaderHandle)
```

关于`Shader`程序本身,顶点函数中只包含了顶点矩阵变换,图元函数中包含了RGB混合alpha直接渲染和YUV转RGB后渲染的程序。这些程序较常规就不再讨论了。

### Extension

对于关键的数据类型,我们可以写一些计算属性来暴露数据指针,例如`GLuint`指针的暴露方法:

```swift
extension GLuint {
var pointer: UnsafeMutablePointer<GLuint> {
mutating get {
var pointer: UnsafeMutablePointer<GLuint>!
withUnsafeMutablePointer(to: &self, { ptr in
pointer = ptr
})
return pointer
}
}
}
```

当OpenGL接口需要一个`UnsafeRawPointer`或`UnsafePointer`时,直接传入`Array`可以自动转换。但当接口需要`UnsafeMutablePointer`时必须实现这类`mutating`方法来暴露可变指针。暴露指针和指针强转的主要的方法:

```swift
func withUnsafeMutablePointer<T, Result>(to arg: inout T, _ body: (UnsafeMutablePointer<T>) throws -> Result) rethrows -> Result
func unsafeBitCast<T, U>(_ x: T, to: U.Type) -> U
```

# OpenGL程序

当封装完上面的准备工作以后,创建OpenGL程序变得十分简单了。

### 初始化 —— glInit()

初始化函数中所要做的事:

- 打开贴图开关和深度测试开关、指定深度测试函数。

```swift
glEnable(GLenum(GL_TEXTURE_2D))
glEnable(GLenum(GL_DEPTH_TEST))
glDepthFunc(GLenum(GL_LESS))
```

- 调用`GLExtension`中封装好的方法创建纹理

```swift
static func gleGenTexture(idPtr: UnsafeMutablePointer<GLuint>)
static func gleUpdateRGBTexture(id: GLuint, filename: String, type: String? = default)
```

- 调用`GLExtension`中封装好的方法创建`Shader`程序

```swift
static func gleBuildShader(sourceFilename: String, type: String = default, shaderType: GLenum) -> GLuint
static func gleBuildProgram(vertexHandle: GLuint, fragmentHandle: GLuint) -> GLuint
```

- 获取`Shader`程序中参数的槽为以备上传

```swift
func glGetAttribLocation(_ program: GLuint, _ name: UnsafePointer<GLchar>!) -> Int32
func glGetUniformLocation(_ program: GLuint, _ name: UnsafePointer<GLchar>!) -> Int32
```

### 重置窗口大小 —— glResize(width:height:)

初始化函数中所要做的事:

- 重置视图大小

```swift
glViewport(0, 0, GLsizei(width), GLsizei(height))
```

- 重置视锥

```swift
func glFrustum(_ left: GLdouble, _ right: GLdouble, _ bottom: GLdouble, _ top: GLdouble, _ zNear: GLdouble, _ zFar: GLdouble)
```

- 重置观察矩阵和世界矩阵

```swift
glMatrixMode(GLenum(GL_PROJECTION))
glLoadIdentity()
glMatrixMode(GLenum(GL_MODELVIEW))
glLoadIdentity()
```

### 绘制方法 —— glDraw()

绘制函数中所要做的事:

- 清除屏幕

```swift
glClearColor(0, 0.2, 0.5, 1.0)
```

- 清除颜色缓冲区和深度缓冲区

```swift
glClear(GLbitfield(GL_COLOR_BUFFER_BIT) | GLbitfield(GL_DEPTH_BUFFER_BIT))
```

- 矩阵变换

```swift
func glTranslatef(_ x: GLfloat, _ y: GLfloat, _ z: GLfloat)
func glRotated(_ angle: GLdouble, _ x: GLdouble, _ y: GLdouble, _ z: GLdouble)
```

- 绘制
- 强制执行以上OpenGL程序

```swift
glFlush()
```

### 清理方法 —— glDeinit()

清理函数中所要做的事:

- 删除贴图和`Shader`程序

```swift
func glDeleteTextures(_ n: GLsizei, _ textures: UnsafePointer<GLuint>!)
func glDeleteProgram(_ program: GLuint)
```

# Cocoa交互

与Cocoa的交互中,除了`NSOpenGLView`中的`draw`方法外还需实现对OpenGL环境的配置,以及几个简单的鼠标操作事件。这些事件就自然交给ViewController实现了。

- 创建OpenGL场景并初始化摄像机

```swift
@IBOutlet weak var glView: MainOpenGLView!

var scene: MainScene!
var cam: MainCamera!

override func viewDidAppear() {
setObj()
}

func setObj() {
scene = MainScene.init()
cam = MainCamera.init(view: glView, scene: scene,
pos: GLEV3.init(0, 0, 10), center: GLEV3.init(0, 0, 0))
scene.setObjs()
cam.glRun()
}
```

- 捕获鼠标点击、拖动以及滚轮动作,相应调整摄像机位置

```swift
var lastMousePos: NSPoint?
override func mouseDown(with event: NSEvent) {
lastMousePos = event.locationInWindow
}

override func mouseDragged(with event: NSEvent) {
if let pos = lastMousePos {
let newPos = event.locationInWindow
let dx = newPos.x - pos.x
let dy = newPos.y - pos.y
cam.dir.x -= Float(dy)
cam.dir.z += Float(dx)
lastMousePos = newPos
}
}

override func scrollWheel(with event: NSEvent) {
cam.pos.z -= min(max(Float(event.scrollingDeltaY), -1), 1)
}

override func mouseUp(with event: NSEvent) {
lastMousePos = nil
}
```

# 总结

本次Demo是一次尝试。Swift不太适合用来写使用大量C接口的程序。指针和选项强制枚举写起来会十分麻烦,但终于也是封装出了一点东西。当然,这样的封装弊端非常明显,物体拆分成三角形一个一个绘制效率会非常低下,当绘制复杂图形时效率会非常低。使用`VBO`等技术来一次性上传顶点至显存再绘制效率会高很多。总之,本Demo也算是可以给想在macOS里用Swift开发OpenGL程序的读者一点参考吧。


评论:0条


返回列表

返回归档

返回主页