在 Cocoa 中使用 Swift 3.0 创建 OpenGL 程序
发布于:2016-10-02 00:17,阅读数:3682,点赞数:9
> 本项目为使用Swift 3.0编写的OpenGL Demo。项目在[「GitHub」](https://github.com/trmbhs/EMMacOpenGLDemo)中开源。 # 导语 本文适合有`OpenGL基础`和`Swift语言基础`以及曾在 C/C++ 中开发过`OpenGL`和`OpenGL SL`程序的读者。Demo将在 Cocoa 中使用 Swift 语言创建一个简单的 OpenGL 程序。 # Demo效果    # 开发环境 macOS 10.12 + Xcode 8.0。 # StoryBoard Storyboard中存在一个`NSOpenGLView`控件,它会为我们创建好了OpenGL上下文(`Context`),可以直接使用。我们的所有OpenGL代码都依赖着这个上下文执行。在界面方面直接将其加入到界面中,附上全屏的AutoLayout,界面就设计完毕了。  使用`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程序运行。  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条