iOS音视频专题ios

OpenGL简介及3D图形基础

2017-11-15  本文已影响492人  RichardJieChen

1 前言

一直想沿着图形处理这条线建立一套完整的图像处理的理论知识体系和积累实际应用经验。因此有了从AVFoundation捕捉图像,OpenGL图像处理的学习过程,人工智能和神经网络图像识别也在计划之中。

该系列文章为经典OpenGl指导蓝宝书OpenGL SuperBible第6版英文版读书笔记,运行环境macOS 10.12.6,Xcode 9.0.1。先说下这里的坑,在Mac上学习这本书不能使用该书的最新版第7版,因为第7版的使用Open GL4.5,然而Macbook pro并不支持该版本。查询你的Mac能支持的OpenGL版本移步至Mac设备支持的OpenGL列表。这里我的Mac支持OpenGL4.1 Profile。因此选用OpenGL SuperBible 6th,其使用的OpenGL版本是OpenGL4.3 Profile。因此书中部分功能无法使用。具体各版本OpenGL支持的功能特性移步至OpenGL历史版本

3D图形处理常用的两个编程语言是OpenGL ARB主导的OpenGL以及Microsoft主导的Direct3D。OpenGL更早发布,但是随后由于Direct3D支持可编程图形硬件特性,并且能及时支持新的GPU特性迅速抢占了OpenGL地位。在DirectX 10后其跟随最新的Windows系统绑定发布新版本,并且更新速度变慢,导致不能及时支持GPU生产商发布的新型GPU特性。OpenGL的ARB委员会加速其研发速度,现在它同样支持可编程图形硬件特性,并且较于Direct3D它及时支持新型GPU特性。ARB正在让它重新占领市场的领导地位。

2 Mac环境搭建

OpenGL在Apple眼中的地位正如Direct3D在Microsoft中一样,Apple为OpenGL的研发提供了很大的支持。但是由于Apple关注用户的体验,希望当前的应用能在Apple系列的所有设备中正常运行,因此Apple官方仅仅采用成熟的OpengGL版本,一些新的特性在Apple中以扩展的形式存在。

在Mac OS中,使用OpenGL框架,另外提供GLUT、GLKit框架。提供GLUT、NSOpenGL、Core GL和GLKit4种技术使用OpenGL。

2.1 查看当前Mac机型支持的OpenGL版本

在Mac上,GPU定义了物理支持的OpenGL版本,Mac OS框架定义了支持的GL API版本。获取前者的信息除了之前提到的在苹果官网中查看,还可以使用AppStore中一个OpenGL Extension Viewer查看。后者信息在NSOpenGLContext中讲解。

2.2 NSOpenGL

NSOpenGL主要包含视图(NSOpenGLView)和配置(NSOpenGLContext)两部分内容,其中后者主要用于配置OpenGL的版本信息等OpenGL方面的配置信息,以及一些视图中使用的渲染颜色空间等信息。

NSOpenGLView只要提供以下4个方法,各个方法的调用时间和必要的配置信息如下。示例使用Storyboard的方式创建NSOpenGLView,尽管UI界面上可以配置部分OpenGL Context的属性,但是由于某些配置仍无法完成。通常都需要使用代码配置,并且需要注意的是,代码中重新设置了上下文,这会使UI界面上的配置全部失效。通常需要在OpenGL Context中配置Color为32bit RGBA、Depth为24bit、Stencil为8bit、Renderer为Accelerated Renderer。更多配置和其具体意义参考头文件和Apple官网。

// Initialize OpenGL view and configure OpenGL Context. Several Contexts can be exist
// on application, but there are only one can be current context for a given thread. 
// Moreover, OpenGL Context can share resouces so we can upload a texture or anything 
// that needed for redering by a background context and then pass these resources to next context.
- (instancetype)initWithCoder:(NSCoder *)decoder {
    // NSOpenGLProfileVersionLegacy is default
    // NSOpenGLPFAColorSize must be always match the screen's color depth
    // 对于BOOL的key,只需要在数组中包含这个key值就表示使用这个配置,对于有具体数值的key值,
    // 其后需要紧跟一个数值,数组以0作为结束标志。
    NSOpenGLPixelFormatAttribute pixelFormatAttributes[] = {
        NSOpenGLPFAColorSize, 32,
        NSOpenGLPFADepthSize, 24,
        NSOpenGLPFAStencilSize, 8,
        NSOpenGLPFAAccelerated,
        NSOpenGLPFAOpenGLProfile, NSOpenGLProfileVersion4_1Core,
        0
    };
    NSOpenGLPixelFormat *pixelFormat = [[NSOpenGLPixelFormat alloc] initWithAttributes:pixelFormatAttributes];
    NSOpenGLContext *openGLContext = [[NSOpenGLContext alloc] initWithFormat:pixelFormat shareContext:nil];
    if (self = [super initWithCoder:decoder]) {
        [self setOpenGLContext:openGLContext];
        [openGLContext makeCurrentContext];
    }
    return self;
}

// This method is called once time only after initialization
- (void)prepareOpenGL {
    glClearColor(1.0f, 0.1f, 0.1f, 1.0f);
    NSLog(@"Version: %s", glGetString(GL_VERSION));
    NSLog(@"Renderer: %s", glGetString(GL_RENDERER));
    NSLog(@"Vendor: %s", glGetString(GL_VENDOR));
    NSLog(@"GLSL Version: %s", glGetString(GL_SHADING_LANGUAGE_VERSION));
}

// This method will call after "prepare" method when OpenGL view is created or each resization of the OpenGL view.
- (void)reshape {
    NSRect bounds = [self bounds];
    glViewport(0, 0, NSWidth(bounds), NSHeight(bounds));
}

// 这个方法执行具体的渲染逻辑
- (void)drawRect:(NSRect)dirtyRect {
    glClear(GL_COLOR_BUFFER_BIT);
    // “glFlush" is istead of some sort of buffer swap call. On Mac OS X, desktop compositing 
    // engine can be saw as front buffer, all OpenGL windows are really single buffered. Given 
    // OpenGL windows are always rendering to an off-screen buffer, when "glFlush" method is 
    // called, system will integrate the openGL rendering to the rest of teh desktop.
    glFlush();
}

2.3 GLKit

GLKit是为了更好从OpenGL ES 1.x 的固定管道编程过渡到使用可编程管道的OpenGL ES 2.0设计的。最初只存在于iOS 5.0平台上,在OS X 10.8时引入Mac OS平台中。GLKit主要包含4个内容,纹理加载、数学库、特效和视图控制器。其中视图控制器部分只能在iOS平台中使用。特效部分主要是对部分固定函数管道(fixed-function pipeline)的进一步包装。

OpenGL处理3D模型是靠向量和矩阵作为数学基础的,在OpenGL库中并不支持这些复杂的运算。于与其他平台需要额外的第三方数学库相比。Apple在GLKit中包含了数学库,数学库的主要目的是进行复杂的向量和矩阵运算,GLKMatrixLookAt甚至可以创建一个基于镜头的矩阵转换,GLKMatrixStack可以最终矩阵变化的历史记录。简单的矩阵运算如下,复杂运算在以后示例中使用。

- (void)resizedWithWidth:(int)width height:(int)height {
    glViewport(0, 0, width, height);
    _mProgection = GLKMatrix4MakePerspective(GLKMathDegreesToRadians(60.0f), width/(float)height, 0.1f, 1000.0f);
    GLKVector3 vLooking = GLKVector3Add(_cameraFrame.vWhere, _cameraFrame.vForward);
    
    _mCamera = GLKMatrix4MakeLookAt(_cameraFrame.vWhere.x, _cameraFrame.vWhere.y, _cameraFrame.vWhere.z, vLooking.x, vLooking.y, vLooking.z, _cameraFrame.vUp.x, _cameraFrame.vUp.y, _cameraFrame.vUp.z);
    
    GLKMatrix4 matrixMVP = GLKMatrix4Multiply(_mProgection, _mCamera);
    GLKMatrix3 mNormal = GLKMatrix4GetMatrix3(_mCamera);
}

- (void)moveForwardWithDistance:(float)distance {
    GLKVector3 vForward = GLKVector3MultiplyScalar(_cameraFrame.vForward, distance);
    _cameraFrame.vWhere = GLKVector3Add(_cameraFrame.vWhere, vForward);
}

- (void)rotateLocalYWithAngle:(float)angle {
    // 绕着镜头z轴向量创建一个旋转矩阵
    GLKMatrix4 rot = GLKMatrix4MakeRotation(angle, _cameraFrame.vUp.x, _cameraFrame.vUp.y, _cameraFrame.vUp.z);
    GLKVector3 vNewForward = GLKMatrix4MultiplyVector3(rot, _cameraFrame.vForward);
    _cameraFrame.vForward = GLKVector3Normalize(vNewForward);
}

GLKit处理纹理主要有两个类。GLKTextureLoader负责加载2D或者3D纹理,并返回一个GLKTextureInfo的实例。另外GLKTextureLoader可以在子线程上使用shared OpenGL context异步加载纹理。GLKTextureInfo包含纹理大小,OpenGL target type等信息。

- (void)initModels {
    NSString *path = [[NSBundle mainBundle] pathForResource:@"rock" ofType:@"png"];
    NSError *error = nil;
    _textureStones = [GLKTextureLoader textureWithContentsOfFile:path options:nil error:&error];
    //convert png file to mipmaps
    glBindTexture(GL_TEXTURE_2D, _textureStones.name);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_NEAREST);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    glGenerateMipmap(GL_TEXTURE_2D);
}

name:纹理的标识符,使用glBindTexture()将其绑定到context中。
target:纹理的类型,只支持GL_TEXTURE_2D和GL_TEXTURE_CUBEMAP。
width; height; depth:以像素为单位的纹理宽、高、深度。
alphaState:保存Alpha通道在颜色数据中的存储方式,可选None、NonPremultiplied、Premultiplied三个类型
textureOrigin:源图片的原点位置,可以是Unknown、TopLeft、BottomLeft,这个属性由GLKTextureLoader设置,代表纹理在加载时是否需要镜像处理
containsMipmaps:Bool值,标识是否包含率纹理(Mipmaps)
mimapLevelCount:率纹理(Mipmaps)的等级数量
arrayLength:数组长度

2.4 Retina屏适配

在Mac Retina屏系列出现以后,对于Cocoa中视图的尺寸都是以point为单位,而OpenGL函数中图像及纹理的尺寸都是以pixel为单位。在非Retina屏幕中它们具有相同的值,但是在Retina屏幕中,它们具有不同的值。Mac OS对于OpenGL默认的渲染规则是1个点渲染1个像素,启用Retina特性的代码级两个单位之间的转换代码如下。

- (instancetype)initWithCoder:(NSCoder *)decoder {
    if (self = [super initWithCoder:decoder]) {
        // 启用全像素渲染
        [self setWantsBestResolutionOpenGLSurface:YES];
    }
    return self;
}

- (void)reshape {
    NSRect bounds = [self bounds];
    // 将点数转化为像素
    NSRect backRect = [self convertRectToBacking:bounds];
    glViewport(0, 0, NSWidth(bounds), NSHeight(backRect));
}

2.5 Core OpenGL

Core OpenGL是最底层的框架,它可以很好的和NSOpenGL中、GLUT或者其他更高级的第三方框架协同工作。另外CGL可以不使用NSOpenGL中视图部分直接创建全屏幕的context,但这在现代的OS X上已经不被鼓励使用。大多数Core OpenGL的方法都需要使用CGL context作为参数,获取当前的CGL context的方法是CGLGetCurrentContext(void)。

2.6 全屏渲染

很多OpenGL程序需要进行全屏渲染,而不是在一个活动窗口中。实现这个目标有两种方式,1)创建一个和整个屏幕一样大的窗口,在OS X10.6之前,这并不是最优的方式。2)使用CGL捕捉屏幕得到更好的全屏渲染效果。在OS X10.6之后,苹果不建议捕捉屏幕,当渲染到全屏幕窗口时,将会得到一个Context标记,OS X会屏幕捕捉方式使用的技术自动优化图像渲染。另外还可以使用双渲染缓存的方式即先将图像渲染至一个更小的缓存中来提升填充性能。捕捉屏幕还会导致UI信息无法在全屏时弹出(如系统提示电量不足信息)。

使用上述第一个方式实现全屏渲染时,在NSOpenGLView的子类中的渲染逻辑时,不再使用glFlush()。可以在程序加载完成后手动实例化NSOpenGLView。

- (void)drawRect:(NSRect)dirtyRect {
    [super drawRect:dirtyRect];
    [[self openGLContext] flushBuffer];
}
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification {    
    NSRect mainDisplayRect = [[NSScreen mainScreen] frame];
    NSWindow *fullScreenWindow = [[NSWindow alloc] initWithContentRect:mainDisplayRect styleMask:NSWindowStyleMaskBorderless backing:NSBackingStoreBuffered defer:YES];
    [fullScreenWindow setLevel:NSMainMenuWindowLevel+1];
    [fullScreenWindow setOpaque:YES];
    [fullScreenWindow setHidesOnDeactivate:YES];
    
    NSOpenGLPixelFormatAttribute pixelFormatAttributes[] = {
        NSOpenGLPFAColorSize, 32,
        NSOpenGLPFADepthSize, 24,
        NSOpenGLPFAStencilSize, 8,
        NSOpenGLPFAAccelerated,
        NSOpenGLPFADoubleBuffer,
        NSOpenGLPFAOpenGLProfile, NSOpenGLProfileVersion4_1Core, 0
    };
    NSOpenGLPixelFormat* pixelFormat = [[NSOpenGLPixelFormat alloc] initWithAttributes:pixelFormatAttributes];
    NSRect viewRect = NSMakeRect(0.0, 0.0, mainDisplayRect.size.width, mainDisplayRect.size.height);
    GLCoreProfileView *fullScreenView = [[GLCoreProfileView alloc] initWithFrame:viewRect pixelFormat:pixelFormat];
    [fullScreenWindow setContentView: fullScreenView];
    [fullScreenWindow makeKeyAndOrderFront:self];
    [fullScreenWindow makeFirstResponder:fullScreenView];
}

2.7 帧率同步

Macbook Pro渲染图像是从上至下逐行刷新,当渲染的帧率和屏幕刷新帧率不一致时,可能导致屏幕刷新到一半时,渲染新的图像,从而导致屏幕上下显示了两帧不同的图像。Macbook Pro的屏幕刷新率为60FPS,我们不能直接设置渲染帧率,但是可以通过设置每帧渲染之前的屏幕刷新率次数kCGLCPSwapInterval来控制渲染帧率。他可以消除图像撕裂现象。可以这样理解,当该值为2时,图像渲染帧率最大为30FPS,当一个渲染操作完成后,负责渲染的线程将会等待两次屏幕刷新时间后继续执行渲染操作。此时后台线程可以完成其他更重要的处理任务。在任何时候都可以调用改变渲染帧率的方法,它会立即生效。需要注意的是,这个并不能保证渲染率一定为30FPS,因为当单个渲染逻辑超时时,会发生丢帧现象。

GLint sync = 1;
CGLSetParameter(CGLGetCurrentContext(), KCGLCPSwapInterval, &sync);

2.8 提升填充效率

填充效率指的是将图像数据转换为帧缓存像素信息的速率。一个简单的办法就是使用更小的窗口,在全屏模式下降低屏幕分辨率。在OS X10.6之前,对于一个全屏幕OpenGL游戏,在运行游戏、捕捉屏幕等操作之前改变屏幕分辨率并不常见。捕捉屏幕并改变屏幕分辨率方法暂不深入研究。在使用双缓存的前提下,我们可以用CGL去改变协调图像缓存的尺寸,使其小于前台图像缓存来提升填充效率。当渲染到屏幕时,协调图像缓存的内容会拉伸填充到整个前台图像缓存。

在改变协调图像缓存尺寸时,必须重新设置glViewport。

- (void) reshape {
  GLint dim[2] = {newWidth, newHeight}; 
  CGLSetParameter(CGLGetCurrentContext(), kCGLCPSurfaceBackingSize, dim);
  CGLEnable(CGLGetCurrentContext(), kCGLCESurfaceBackingSize);
  glViewport(0, 0, newWidth, newHeight);
}

2.9 OpenGL多线程操作

OpenGL驱动会先通过CPU处理一部分计算,然后最终将其交付到GPU渲染。在OS X 10.5以后,可以启用多线程OpenGL核心将这些任务交给多个线程处理。在多核CPU设备上,这样能再一定程度上提升性能。但其提升效果有限,甚至有时会降低性能。当有关OpenGL执行的代码的执行性能不受限于CPU时,通常不会有明显的性能提升。另外,调用大量的生成渲染管道函数(glGetFloatv(), glGetIntegerv(), glReadPixels()等)也会影响其性能提升潜力。

CGLEnable(CGLGetCurrentContext(), kCGLCEMPEngine);

2.10 GLUT

GLUT的全称为OpenGL Utility Toolkit,一个基于窗口系统的OpenGL编程工具。最早于1994年发布,初期定位为说明和学习框架,随后不断扩展,支持基本的游戏编程特性。尽管现在不是主流的工具,但是Apple针对OS X进行了部分优化。

创建基于GLUT的程序需要移除空白项目中的AppDelegate.m/.h和main.m文件,并创建包含GLUT的C/C++文件。

int main(int argc, char* argv[]) {
  glutInit(&argc, argv);
  glutInitDisplayMode(GLUT_DOUBLE | GLUT_RGBA | GLUT_DEPTH | GLUT_STENCIL | GLUT_3_2_CORE_PROFILE);
  glutCreateWindow("GLUT Core Profile Demo");
  glutReshapeFunc(ChangeSize);
  glutKeyboardFunc(KeyPressFunc);
  glutDisplayFunc(RenderScene);
  SetupRC();
  printf("Version: %s\r\n", glGetString(GL_VERSION));
  printf("Renderer: %s\r\n", glGetString(GL_RENDERER));
  printf("Vendor: %s\r\n", glGetString(GL_VENDOR));
  printf("GLSL Version: %s\r\n",   
  glGetString(GL_SHADING_LANGUAGE_VERSION));
  glutMainLoop(); 
  return 0;
}

3 OpenGL简介

OpenGL是一个跨平台的图形编程接口,他可以在Linux、Windows、Mac OS、iOS、Android等各种平台上使用。

3.1 OpenGL和图形管道

OpenGL的工作方式类似工厂中的流水线,OpenGL接受来自程序的命令,并将这些命令发送到底层的图形硬件。在图形硬件中,大量的指令排队并发运行,队列中某个指令前阶段可能后另一个指令的后阶段同时执行。进一步说,计算机图形通常由很多重复的任务块(如计算一个像素应该是什么颜色)组成,这些任务之间相互独立。OpenGL将程序发送给它的工作分解为很基础并且并行的任务块。现代的图形处理器通过平行的流水线生成模式将其组装从而达到非常高的性能。

OpenGL的目标是在程序和底层图形处理子系统之间提供一个抽象的层,其中底层图形处理子系统通常由一个或者多个带有专用存储的高性能处理器组成。OpenGL需要平衡它的抽象程度,使它能够屏蔽不同硬件和系统带来的编程复杂性,同时兼顾能够充分使用硬件特性。游戏引擎的抽象程度通常很高,视频游戏机(video game consoles)的抽象程度通常非常低。

现代GPU展现出的强大计算能力使它不仅仅局限于图形处理,它还可以在物理模拟、人工智能甚至音频处理中得到充分利用。现代CPU由大量被称为着色器核心(shader core)的小型可编程处理器组成。每个核心都运行着迷你程序(shader),执行简单有限和单一的计算指令。图形处理可以分为几个阶段,每个阶段由一个着色器或者一个固定函数组成。


图中直角矩阵代表可编程的阶段,执行程序提供的shader,圆角矩形代表固定函数,实际上一些固定函数仍然是用着色器语言实现的,只是它由GPU供应商提供预制的代码。

3.2 OpenGL起源和发展

OpenGL起源于Silicon Graphics公司的IRIS图像实验室,最早是为其高端显卡服务的专有API,1992年时将其开源发布OpenGL 1.0版本。当年,OpenGL架构审核委员会(ARB)在其组织下成立,最初成员包括IBM, Intel, and Microsoft等公司。ARB负责设计管理和发布OpenGL描述和规范。现在该委员会是Khronos Group的一部分,OpenGL目前最新的版本是4.5。

2008年ARB将OpenGL分为两个部分,core profile和compatibility profile。core profile只保留现代GPU支持的较新部分接口。后者支持至OpenGL 1.0的接口。在开始新项目是应该使用前着,维护老项目时使用后者。值得注意的是,在部分平台上,更新的特性只支持在core profile中使用。另外使用前者编写的程序其运行速度也要高于后者。

3.3 图元、管线和像素

当收到来自程序的命令时,数据从管道的一端流入,在流动的过程中,shader和fixed-function块会从buffer和texture中抓取更多的数据。某些阶段甚至存储数据到这些buffer和texture,最后再管道另一端返回处理完成的数据。

OpenGL中基本的渲染单元为图元(primitive),OpenGL支持多种图元,他们由点、线和三角这三个基本图元组成。程序将复杂表面分解为大量三角然后将其交付给GPU,GPU使用其内置的光栅器(rasterizer)将三维的三角形转化为一些列的像素点用于随后的渲染行为。

点、线和三角分别由1、2或多个顶点组成,顶点主要位于三维空间坐标系中。图形管道通常分为两部分,第一部分称为前端(front end),它负责处理顶点和图元,最终将它们转化为点、线和三角,然后将其交付给光栅器,这一过程称为初始装配(primitive assembly)。经过光栅化器后,由向量构成的几何图形被转化为大量相互独立的像素点。从这里开始进入后端(back end)的处理过程,其中包括深度(depth)和模板(stencil)测试、片段着色、混合和生成输出图像(updating the output image)。

4 渲染一个三角形

这里通过一个简单的例子来了解最基本的OpenGL程序组成,以及着色器语言编写方法。首先在Xocde中新建一个mac OS目录下的Cocoa App程序,在主窗口中添加NSOpenGLView的子类GLCoreProfileView,如前文描述。这里使用Shader.vsh和Shader.fsh文件分别用于编写顶点着色器和片段着色器。并且在工程中导入OpenGL和GLKit框架。完成后的文件目录如下所示。

一个基本的OpenGL程序包含以下几个步骤。

准备顶点着色器

// 指定了OpenGL版本为 4.1 Core Profile
#version 410 core
void main() {
    // 通常顶点数据是通过外部传入,这里简单示例采用另外一种方式gl_VertexID,它检查外面调用的方法glDrawArrays(GL_TRIANGLES, 0, 3);
    // 从第一个参数开始取顶点,直到最后一个参数定义的顶点数量。
    // The gl_VertexID input starts counting from the value given by the first parameter of glDrawArrays()
    // and counts upwards one vertex at a time for count vertices (the third parameter of glDrawArrays())
    const vec4 vertices[3] = vec4[3](vec4( 0.25, -0.25, 0.5, 1.0),
                                     vec4(-0.25, -0.25, 0.5, 1.0),
                                     vec4( 0.25, 0.25, 0.5, 1.0));
    // gl_Position为GLSL中的内部的输出变量,这个输出变量OpenGL内部会用于拓扑重建等任务(Tessellation)。
    gl_Position = vertices[gl_VertexID];
}

准备片段着色器

#version 410 core
// Out表示输出变量,这个输出变量会被OpenGL捕捉,并用于图形的渲染
out vec4 FragColor;
void main() {
    FragColor = vec4(0.0, 0.8, 1.0, 1.0);
}

创建和编译着色器、链接程序、创建VAO

- (void)prepareOpenGL {
    [self loadShaders];
    glGenVertexArrays(1, &_vertexArray);
    glBindVertexArray(_vertexArray);
}

- (BOOL)loadShaders {
    GLuint vertexShader;
    GLuint fragShader;
    NSString *vertexShaderPathName;
    NSString *fragShaderPathName;
    
    _program = glCreateProgram();
    
    vertexShaderPathName = [[NSBundle mainBundle] pathForResource:@"Shader" ofType:@"vsh"];
    if (![self compileShader:&vertexShader type:GL_VERTEX_SHADER filePath:vertexShaderPathName]) {
        NSLog(@"Failed to compile vertex shader");
    }
    fragShaderPathName = [[NSBundle mainBundle] pathForResource:@"Shader" ofType:@"fsh"];
    if (![self compileShader:&fragShader type:GL_FRAGMENT_SHADER filePath:fragShaderPathName]) {
        NSLog(@"Failed to compile fragment shader");
    }
    
    glAttachShader(_program, vertexShader);
    glAttachShader(_program, fragShader);
    
    if (![self linkProgram:_program]) {
        NSLog(@"Failed to link program: %d", _program);
        if (vertexShader != 0) {
            glDeleteShader(vertexShader);
            vertexShader = 0;
        }
        if (fragShader != 0) {
            glDeleteShader(fragShader);
            fragShader = 0;
        }
        if (_program != 0) {
            glDeleteProgram(_program);
            _program = 0;
        }
        return NO;
    }
    
    if (vertexShader != 0) {
        glDetachShader(_program, vertexShader);
        glDeleteShader(vertexShader);
    }
    if (fragShader != 0) {
        glDetachShader(_program, fragShader);
        glDeleteShader(fragShader);
    }
    return YES;
}

- (BOOL)compileShader:(GLuint *)shader type:(GLenum)type filePath:(NSString *)path {
    const GLchar *shaderSource = [NSString stringWithContentsOfFile:path encoding:NSUTF8StringEncoding error:nil].UTF8String;
    *shader = glCreateShader(type);
    glShaderSource(*shader, 1, &shaderSource, nil);
    glCompileShader(*shader);
    
    GLint status = 0;
    glGetShaderiv(*shader, GL_COMPILE_STATUS, &status);
    if (status == 0) {
        GLint logLen = 0;
        glGetShaderiv(*shader, GL_INFO_LOG_LENGTH, &logLen);
        GLchar *infoLog = malloc(sizeof(char) * logLen);
        glGetShaderInfoLog(*shader, logLen, NULL, infoLog);
        NSLog(@"Shader at: %@", path);
        fprintf(stderr, "Info Log: %s\n", infoLog);
        
        glDeleteShader(*shader);
        return NO;
    }
    return YES;
}

- (BOOL)linkProgram:(GLuint)program {
    glLinkProgram(program);
    GLint status = 0;
    glGetProgramiv(program, GL_LINK_STATUS, &status);
    if (status == 0) {
        GLint logLen = 0;
        glGetProgramiv(program, GL_INFO_LOG_LENGTH, &logLen);
        GLchar *infoLog = malloc(sizeof(char) * logLen);
        glGetProgramInfoLog(program, logLen, NULL, infoLog);
        fprintf(stderr, "Prog Info Log: %s\n", infoLog);
        return NO;
    }
    return YES;
}

渲染图形

- (void)drawRect:(NSRect)dirtyRect {    
    const GLfloat color[] = { (float)sin(_lifeDuration) * 0.5f + 0.5f, (float)cos(_lifeDuration) * 0.5f + 0.5f,
        0.0f, 1.0f };
    glClearBufferfv(GL_COLOR, 0, color);
    
    // 当glDrawArrays画顶点时可以指定点的大小
//    glPointSize(40);
    glUseProgram(_program);
    glDrawArrays(GL_TRIANGLES, 0, 3);
    glFlush();
}

5 管线(Pipeline)

在OpenGL中VertexShader是第一个可编程的步骤,但是它并不是管线的第一个步骤,管线的第一个步骤是顶点抓取(vertex fetching),这是一个固定函数,系统负责调用并给顶点着色器提供输入数据。

5.1 向顶点抓取模块传入数据

在着色器中GLSL提供了in关键字用于定义输入属性,对于顶点着色器,顶点抓取模块(vertex fetching stage)负责为这些输入变量提供数据。顶点抓取模块是一个固定函数,可以使用类似于glVertexAttrib*()的函数来告诉它执行某些特定的操作。
在着色器中定义输入变量

#version 410 core
// layout关键字和location关键字指定了该输入的索引值为0,后面使用glVertexAttrib4fv函数时使用相同的值就能完成数据通信
layout (location = 0) in vec4 offset;
void main() {
    const vec4 vertices[3] = vec4[3](vec4( 0.25, -0.25, 0.5, 1.0),
                                     vec4(-0.25, -0.25, 0.5, 1.0),
                                     vec4( 0.25, 0.25, 0.5, 1.0));
    gl_Position = vertices[gl_VertexID] + offset;
}

和顶点抓取模块数据通信

- (void)drawRect:(NSRect)dirtyRect {    
    const GLfloat color[] = { (float)sin(_lifeDuration) * 0.5f + 0.5f, (float)cos(_lifeDuration) * 0.5f + 0.5f,
        0.0f, 1.0f };
    glClearBufferfv(GL_COLOR, 0, color);
    glUseProgram(_program);
    GLfloat attrib[] = { (float)sin(_lifeDuration) * 0.5f, (float)cos(_lifeDuration) * 0.6f,
        0.0f, 0.0f };
    // Update the value of input attribute 0
    glVertexAttrib4fv(0, attrib);
    glDrawArrays(GL_TRIANGLES, 0, 3);
    glFlush();
}

5.2 不同模块间数据通信

正如定义了in关键字,GLSL还定义了out关键字用于输出变量,任意一个用out关键字定义在某个模块中的变量,在其下一个模块都能使用同样的标识符通过in关键字获取。在该示例中变量直接被顶点着色模块传递到片段着色模块是由于中间模块并未被激活。

5.2.1 传递简单数据

顶点着色器接受变量color,输出变量vsColor

#version 410 core
layout (location = 0) in vec4 offset;
layout (location = 1) in vec4 color;
out vec4 vsColor;

void main() {
    gl_Position = ...;
    vsColor = color;
}

片段着色器接受变量vsColor

#version 410 core
in vec4 vsColor;
out vec4 FragColor;

void main() {
    FragColor = vsColor;
}

渲染时向顶点着色器传入变量

- (void)drawRect:(NSRect)dirtyRect {    
    ...
    glVertexAttrib4fv(0, attrib);
    GLfloat fragColor[] = {1.0f, 0.0f, 0.0f, 1.0f};
    glVertexAttrib4fv(1, fragColor);
    ...
}
5.3.2 传递复杂数据结构

GLSL允许在shader之间以结构体的方式传递数据。这样有两个好处,第一:允许输出变量和输入变量名称不同,只需要他们结构的定义相同。第二:允许用同一个变量传递多个数据。但是需要注意的是这种方式不允许在向vertexshader的输入和fragment的输出中使用。
顶点着色器

#version 410 core
layout (location = 0) in vec4 offset;
layout (location = 1) in vec4 color;
out VS_OUT {
    vec4 color;
} vs_out;

void main() {
    gl_Position = ...;
    vs_out.color = color;
}

片段着色器

#version 410 core
in VS_OUT {
    vec4 color;
} fs_in;
out vec4 color;

void main() {
    color = fs_in.color;
}

5.3 曲面细分(Tessellation)

曲面细分指的是将大的多边形分为大量的较小的点、线和三角,使这些小图元能直接被光栅化器使用。在逻辑上,曲面细分阶段紧紧跟着ShaderVertex模块,拓扑重建由一个可编程曲面细分控制模块(tessellation control shader)、一个固定函数曲面细分引擎(tessellation engine)和曲面细分评价器组成(tessellation evaluation shader)。

5.3.1 曲面细分控制模块(tessellation control shader)

曲面细分将图形补丁组(patches)分解为大量的较小的点、线和三角(此例只包含一个补丁,由三个控制点组成的三角形)。可以通过glPatchParameteri()设置每个补丁(patch)的控制点个数(pname = GL_PATCH_VERTICES)。不调用此函数时,默认设置为3。当启用曲面细分时,OpenGL会为每个控制点调用一次vertex shader,同时曲面细分控制程序将控制点分组进行批量处理,其每批处理的控制点大小和每个补丁(patch)的控制点数量相同(每处理一个patch后该着色器代码才会执行)。控制模组生成的每批的控制点可以改变,从而使得模组的消耗顶点数量不同于输出顶点数量。

该模块主要负责两件事情:1:确定曲面细分引擎需要的拓扑重建级别。2:生成曲面细分评价器需要的数据,即将原始补丁控制点做自定义修改后传入评价器程序。

在编写曲面细分控制模组时,对于数据的传递有如下内建变量。
layout (vertices = 3) out;vertices定义了控制模组每批处理输出的控制点个数。
gl_InvocationID;控制模组内部用的数组索引,其范围和输入控制点个数相关。
gl_TessLevelInner;输出的图元内部曲面细分因子数组(可以只设置首个)。
gl_TessLevelOuter;输出的图元边界曲面细分因子数组(容量和输出控制点个数相同,需要全部赋值)。
gl_out、gl_in:输出和输入的控制点数组。

#version 410 core
layout (vertices = 3) out;
void main(void) {
    if (gl_InvocationID == 0) {
        gl_TessLevelInner[0] = 5.0;
        gl_TessLevelOuter[0] = 5.0;
        gl_TessLevelOuter[1] = 5.0;
        gl_TessLevelOuter[2] = 5.0;
    }
    gl_out[gl_InvocationID].gl_Position = gl_in[gl_InvocationID].gl_Position+vec4(0.25,0,0,0.0);
}
5.3.2 曲面细分引擎(The Tessellation Engine)

曲面细分引擎是一个固定函数,它根据前一个模组传入的细分因子将图形补丁组(patches)细分为大量点、线和三角。当细分引擎将图形补丁组分解后,代表基础图元的顶点数组被曲面细分评估函数获取。细分引擎负责为评估函数生成参数,用于转换图元和为光栅化做准备。

5.3.3 曲面细分评估函数(Tessellation Evaluation Shaders)

当前一个模组将图形补丁组(patches)细分为大量点、线和三角,生成大量顶点后。OpenGL会为每一个顶点调用一次评估函数。曲面细分等级越高时,曲面评估函数耗时越长。需要注意这点,并且避免复杂的评估函数。评估函数接受来自控制函数的输出控制点(gl_in),并以此为基础建立重心坐标系(barycentric coordinate system),同时接收细分引擎传入顶点的重心坐标(gl_TessCoord.x)。通过设置细分模型、多边形细分规则、细分图元计算方向来计算出每个顶点的坐标。

#version 410 core
layout (triangles, equal_spacing, cw) in;
void main(void) {
    gl_Position = (gl_TessCoord.x * gl_in[0].gl_Position + gl_TessCoord.y * gl_in[1].gl_Position + gl_TessCoord.z * gl_in[2].gl_Position);
}

为了观察具体效果,在渲染时调用glPolygonMode()函数控制渲染模式,此例中使用GL_FRONT_AND_BACK作为其参数face表示除了视角能看见的面,视角不可见的面也需要绘制,使用GL_LINE作为mode参数表示只绘制轮廓。另外绘制函数也需要改为GL_PATCHES而不再绘制三角。

void glPolygonMode(GLenum face, GLenum mode);
glDrawArrays(GL_PATCHES, 0, 3);

当启用上述两个着色器,再修改本次示例中上文的顶点着色器和片段着色器后得到下图结果。

5.4 几何着色器模组(Geometry Shaders)

几何着色器模组理论上是管道前端的最后一个模组,位于曲面细分模组和光栅化模组之间。每处理一个图元,几何着色器将会被调用一次,并接收组成该图元的所有顶点数据。在众多模组间,其具有独特的性质。一:它可以通过两个函数(EmitVertex() and EndPrimitive())来直接改变在管道中流动的数据量()。二:它可以改变图元的类型,如将三角分解为顶点输出,或者将顶点组合为三角输出。

#version 410 core
layout (triangles) in;
layout (points, max_vertices = 3) out;
void main(void) {
    int i;
    for (i = 0; i < gl_in.length(); i++) {
        gl_Position = gl_in[i].gl_Position;
        EmitVertex();
    }
}

上面代码将triangles作为几何着色器的输入图元类型,输出点图元类型,每个shader最多输出3个顶点。在为每个顶点计算出输出位置后,调用EmitVertex()生成一个顶点。在着色器的末尾,OpenGL会自动调用EndPrimitive()函数将当前缓存中的图元形成points定义的图元并将其输出,随后清空画布。

在前文示例的基础上启用本小节中的几何着色器,并将每个点的大小通过调用glPointSize()函数设置为5个像素后得到下图所示结果。

5.5 图元的装配,剪切和光栅化

当管道前端(vertex shading, tessellation, and geometry shading)运行完成后,一个固定函数执行了一系列的任务将顶点转化为像素。在这一系列复杂的任务之中,第一步为图元装配(primitive assembly),它将顶点分为线、三角和点图元。随后OpenGL将会对其不可见部分进行剪切(由视图窗口控制viewport)。最后可见图元被发送到光栅化器(rasterizer)。光栅化器将图元分解为像素,并将这些像素点发送给片段着色器进行进一步处理。

5.5.1 剪切(Clipping)

在OpenGL中点的位置信息,正如在各个shader中使用的glPosition变量都是由4个坐标组成的,它们被称为齐次坐标(homogeneous coordinate)。在投影几何中使用齐次坐标空间表示点比使用常规的笛卡尔空间更简单(Cartesian space)。在将顶点从齐次坐标系转化为笛卡尔坐标系的过程中,OPenGL执行了透视除法(perspective division),即将前三个元素(x.y.z)都除以第4个元素(w)。

经过投影除法后,所有点都位于常规设备空间内(normalized device space)。在OpenGL内可见的常规设备空间在x、y轴上的范围是(-1.0至1.0),在z轴上的范围是(0.0至1.0)。在这个范围内的图元部分都是可见的,在这个范围外的部分都将会被丢弃。对于每一个图元,如果图元的所有顶点都在可视范围内,这个图元将直接输出到光栅化器;如果图元的所有顶点全部在可视范围内,整个图元将会被丢弃;如果图元部分顶点在可视范围内,将会执行额外操作,将在下文讲到剪切章节时解释。

5.5.2 视口变换(Viewport Transformation)

经过剪切后,所有的顶点都位于常规设备空间内(normalized device space)。但是我们在屏幕上显示的窗口坐标空间,其距离单位为像素,范围从左下的(0,0)直至右上角的(w-1,h-1)。OpenGL执行视口变换操作,通过平移和和缩放的操作将顶点从常规设备空间转换到窗口坐标空间(window coordinates)。窗口坐标空间通过调用glViewport()和glDepthRange()函数设置。

公式中xw,yw,zw为转后顶点的坐标,px,py为视口的宽和高,ox,oy为视口的原点,xd,yd,zd为转换前位于标准设备空间内的坐标。

5.5.3 精选(Culling)

在三角形真正被处理之前,可能会经过一个精选阶段。该阶段计算确定了三角形是面向观察者还是背向观察者,同时确定三角形是否会被管道中下一个阶段的程序处理。OpenGL通过front-facing和back-facing来表示三角形的朝向,通常,背向观察者的三角形都会被丢弃,因为对于封闭的图形,这些表面都将被隐藏。但是绘制透视图形的时候这些表面不能被丢弃。

在决定三维空间中的一个三角形在z轴上是背向观察者或者是面向观察者时,OpenGL采用计算其在xy投影三角有向面积的方式。一种计算有向面积的方式是计算任意两条边的交叉相乘差的和(take the cross product of two of its edges)。公式如下。

其中xiw和ywi 分别是第i个顶点的窗口坐标,i⊙1是(i+1)对3求余。如果结果是正数,这个三角形图元被认为是面向观察者的,如果是负数则是背向观察者了,只有当三个顶点在同一直线上时,该值为0。另外可以通过调用glFrontFace()函数取得相反结果,其默认设置是GL_CCW(三角形顶点出现顺序为逆时针的三角形图元被认为是面向观察者图元By default, this state is set to GL_CCW, indicating that triangles whose vertices are in counterclockwise order are considered to be front-facing),当设置为GL_CW表示三角形顶点出现顺序为正时针,此时OpenGL会默认为每个有向面积取负数。

另外一种计算三角形有向面积的方式是通过行列式的方式计算。其中x0,y0,x1,y1,x2,y2分别代表顶点V0,V1,V2。A表示三角形的有向面积。

在计算出三角形的朝向状态后,程序中通过glEnable(GL_CULL_FACE)函数启用精选模组,默认会剔除背向观察者的三角形图元(back-facing)(When you enable culling, OpenGL will cull back-facing triangles by default)。可以通过函数glCullFace() 和参数GL_FRONT, GL_BACK, GL_FRONT_AND_BACK设置需要剔除的三角形种类。

点图元和线图元没有几何面积,因此精选阶段将会忽略这些图元。并且该阶段三角形图元的顶点顺序和vertexshader中顶点输入顺序一级tesselation evaluation shader中的顶点输入顺序相关,此实例vshader输入顺序为cw,teshader输入顺序为cw,但是在计算culling阶段时默认过滤后向图元,在使用默认计算朝向参数设置时,即会剔除掉cw顶点顺序的三角形图元。可见从teshader到culling阶段OpenGL内部进行了某些转换,使得三角形顶点顺序反向。

5.5.3 光栅化(Rasterization)

光栅化模组会计算哪些片段在线或者三角图元内。大多数OpenGL系统在光栅化三角形时采用半空间(half-space-based)计算方法一遍能够并行处理大量计算任务。具体的计算方式是,OpenGL会为窗口坐标系中的三角形图元限定一个边界框,将在框里的每一个像素分别和三角形的三条边比较,向量的叉乘(点向量分别和三条连续相连边向量叉乘)和向量点乘(上一步三个向量分别两两点乘)确定点是否在三条边的同一侧。这种方式使得每个像素的计算都相对独立,它只考虑三角形某一边的两个端点和片段自己的位置,使得大规模并行计算得以进行。只有同时在三条边内的片段才会被传递到片段的下一个模组。

5.6 片段着色器(Fragment Shaders)

片段着色器是图形管道的最后一个可编程模组,它计算出每个片段的颜色并将其输入到帧缓存中。这里是图形管道中计算量最大的一个模组。光栅化过程可能会为每个图元生成甚至上百万个片段。前文示例中的片段着色器代码非常简单,通常这里的逻辑会更复杂,它需要执行与光照(lighting)、纹理贴图(applying materials)和片段深度(depth of the fragment)相关的计算逻辑。gl_FragCoord是一个重要的输入内部变量,它包括了片段在窗口中的坐标。片段着色器同样可以接受来自前一个着色器传入的变量,但是这里的输入变量不同于管道中其他模组接收的输入变量,该阶段的输入变量会在图元的不同片段之间发生变化。

这里启用只启用顶点着色器和片段着色器。它们的代码分别如下。
顶点着色器

#version 410 core
out vec4 vs_color;
void main(void) {
    const vec4 vertices[3] = vec4[3](vec4( 0.25, -0.25, 0.5, 1.0),
                                     vec4(-0.25, -0.25, 0.5, 1.0),
                                     vec4( 0.25, 0.25, 0.5, 1.0));
    const vec4 colors[] = vec4[3](vec4( 1.0, 0.0, 0.0, 1.0),
                                  vec4( 0.0, 1.0, 0.0, 1.0),
                                  vec4( 0.0, 0.0, 1.0, 1.0));
    gl_Position = vertices[gl_VertexID];
    vs_color = colors[gl_VertexID];
}

片段着色器

#version 410 core
in vec4 vs_color;
out vec4 color;
void main(void) {
    color = vs_color;
}

当使用上述代码替换前文中的片段着色器后得到下图结果。可以看见顶点着色器只为其每个顶点指定了固定的颜色,但是OpenGL内部会根据片段距离三个顶点的距离计算出该片段的真实颜色值。

5.7 帧缓存操作(Framebuffer Operations)

帧缓存模组是管道的最后一个模组,它负责计算屏幕上可见内容,并且管理一块内存区域用于存储每个像素点的数据(that are used to store per-pixel values other than color)。在大多数平台上,帧缓存和操作系统(更准确的将是窗口系统)管理的当前窗口(当前窗口占满整个屏幕时即指代整个屏幕)相关联。窗口系统提供了默认帧缓存,当需要进行离屏渲染时也可以提供指定的帧缓存。帧缓存会保存多个状态(states),例如确定片段着色器产生的数据应该写入的位置(where the data produced by your fragment shader should be written),以及数据的格式等。这些状态保存在一个帧缓存对象(framebuffer object)中。可以认为部分帧缓存对象保存的都是像素操作状态(Also considered part of the framebuffer, but not stored per framebuffer object, is the pixel operation state)。

5.7.1 像素操作(Pixel Operations)

在片段着色器输出像素数据和将其渲染到屏幕上之间还要经过几个操作来确定其是否应该被放入窗口中(such as whether it even belongs in the window)。每个操作都可以在程序中开启或禁用。

第一个操作是剪切测试(scissor test),它负责测试片段是否位于程序定义的矩形框内,矩形框内的片段输出到下一个任务,矩形框外的片段将会被丢弃。

接下来是模板测试(stencil test),它负责比较程序提供的引用值和模板缓存的内容(This compares a reference value provided by your application with the contents of the stencil buffer)。模板缓存为每个向量存储一个特定的值(which stores a single value per-pixel)。这些值没有任何特定的语义,它能被用于任何目的。(当多重后面章节中多重采样功能被启用时候,帧缓存能同时存储一个像素的景深、模板和颜色数据)。

接下来是深度测试(depth test),比较片段的z轴坐标分量和深度缓存(depth buffer)中的数据(compares the fragment’s z coordinate against the contents of the depth buffer)。深度缓存是帧缓存的另一部分,它包含了每个像素的景深(像素和观察者的距离)信息。通常景深缓存中存储的数据取值范围有近到远为0~1。

在对窗口中同一个坐标点进行多次渲染时,OpenGL会比较当前待渲染片段基于窗口坐标系的z轴坐标分量,和已经渲染在缓存中的片段对应的景深数据。如果当前片段的对应景深数据更小,当前片段就会替代原有片段(OpenGL can compare the z component of the fragment’s window-space coordinate against the value already in the depth buffer, and if it is less than what’s already there, then the fragment is visible)。这个测试的结果是可以控制的,对于z轴坐标分量大于,等于或者不等于景深缓存的片段,都可以通过函数使得OpenGL对其的景深测试通过(The sense of this test can also be changed. For example, you can ask OpenGL to let fragments through that have a z coordinate that is greater than, equal to, or not equal to the content of the depth buffer)。另外景深测试的结果同样也会影响OpenGL对模板测试的处理结果。

接下来根据片段的颜色值的类型(小数,标准化数据,或者整数floating-point, normalized, or integer values),他们会被发送给混合和逻辑操作模组(blending or logical operation stage)。如果是小数或者标准版整形数据(floating-point or normalized integer values),混合模组将会被启用。OpenGL提供大量的函数可以获取片段着色器输出的结果和当前帧缓存的各个分量,并计算出一个新的值返回给帧缓存。(Blending is a highly configurable stage in OpenGL and will be covered in detail in its own section. In short, OpenGL is capable of using a wide range of functions that take components of the output of your fragment shader and of the current content of the framebuffer and calculate new values that are written back to the framebuffer)。如果帧缓存包含的是未标准化的整形数据,OpenGL将会对片段着色器的输出和当前帧缓存执行AND、OR、XOR等逻辑操作,并计算出新的值返回给帧缓存。

5.8 计算着色器(Compute Shaders)

前文分析了OpenGL图形管道的各个模组,但是OpenGL中还独立于图形管道之外运行着计算着色器。它可以被认为是位于顶点、曲面细分、几何或片段着色器集合内部的单模组管道(Unlike the graphics-centric vertex, tessellation, geometry, and fragment shaders, compute shaders could be considered as a special, single-stage pipeline all on their own)。每次计算着色器的调用被称为工作块(work item),多个工作块形成一个工作组(local workgroups)。这些工作组的集合被发送到OpenGL的计算管道(compute pipeline)进行处理。除了一些告诉shader正在被计算块的标识的内部变量,计算着色器不含任何固定的输出或者输入变量,其所有的计算结果都将写入内存中。

#version 410 core
layout (local_size_x = 32, local_size_y = 32) in;
void main(void) {
    // Do nothing
}

计算着色器的编译链接方法和顶点着色器方法相同。上述代码定义了一个简单的计算着色器,其中规定了单个工作组的容量是32*32个工作块(The shader tells OpenGL that the size of the local workgroup is going to be 32 by 32 work items)。关于计算着色器的使用后文将会详细讲解。

6 3D图形数学基础

在3D图形处理中,OpenGL通过向量和矩阵之间的乘法来实现对3D图形的变形、缩放以及旋转等操作,这里需要用到一些线性代数的数学知识。文中部分图片来自于3D Math Primer for Graphics and Game Development。

6.1 向量(Vectors)

顶点(vertex)是OpenGL的主要输入对象,每个顶点可以看做是三维空间中的一个向量。OpenGL从顶点数组中建立三角形式,通过在三维空间中连接各个顶点形成了一个有向矢量三角形。在GLSL中三维向量和四维向量分别被表示为vec3和vec4。对于向量OpenGL关心起方向(direction)和长度(magnitude)两个信息。在OpenGL中经常会用到标准化值以及标准化向量,其标准化(normalization)概念和数学中的标准化向量(1, 0, 0)相同,即向量长度为1。

OpenGL用于表示三维空间中的方向向量通常只需要使用(x,y,z)三个坐标分量即可,如用于计算光照时使用的平面法向量。但是当处理三维物体变形时需要和4*4的矩阵做乘法运算,因此在做矩阵变化时必须使用4维向量。

简单向量运算
简单的向量运算时两个向量维度必须相同,运算时两个向量中对应元素分别应用特定运算规则。

向量的点乘(dot product)
向量的点乘(dot product)也称为向量的内积(inner product)。运算结果等于其各个元素相乘之和(v1 * v2 = v1.x * v2.x + v1.y * v2.y + v1.z * v2.z),在几何意义上其结构为两个向量的长度和其夹角的余弦值的积。在漫射光照(diffuse lighting)环境下,点乘被广泛用于平面的单位法向量和朝向光源的关照法向量之间。

向量的叉乘(Cross Product)
向量的叉乘也成为矢量积,线性代数上的运算时将两个向量和一个单位向量组合形成一个矩阵,借助部分行列式的运算规则得到新的向量。向量的叉乘也是一个向量。几何意义上其表示为垂直于被计算两个向量组成平面的向量,另外对于向量v1和v2叉乘得到的向量v3,v3的长度等于v1的长度和v2的长度及它们之间的正弦乘积,即两个向量构成的平行四边形的面积。另外不同于点乘,向量的叉乘顺序会影响其最终的计算结果。

向量a和向量b的叉乘可以通过下列行列式帮组运算,通常i,j,k取单位向量(1,1,1)。a×b = ((aybz-azby)i, (azbx-axbz)j, (axby-aybx)*k)。结果向量的长度等于三角形面积的两倍。

向量v2×v1叉乘的几何意义可以表示为下图,其中v3垂直于平面v1-v2,其遵循右手螺旋法则。OpenGL中大量使用向量叉乘,通过计算三角形的法向量来构建一个转换矩阵(transformation matrices)。

折射向量和反射向量(Reflection and Refraction)
下图中Rin为入射标准向量,N为界面的标准法向量,Rreflect为反射标准向量。Rfract为不同折射率的标准折射向量。

OpenGL计算反射向量时,入射向量和反射面的法向量都必须使用标准向量,这样,其计算结果只包含方向信息。反射向量的计算方法如下。

OpenGL计算折射向量时需要指定折射率,其计算方法如下。

6.2 矩阵(Matrices)

在OpenGL中图形的变化几乎都是通过矩阵运算的方式实现。顶点v绕任意点以任意方向旋转的新顶点vn,vn的x坐标分量不仅与原顶点v的x坐标分量及旋转参数相关,还与v的y、z轴坐标分量相关。数学上的矩阵用行列式的形式表示,而OpenGL中矩阵以二维数组的形式表示。矩阵之间可以做加法和乘法运算,一个向量乘以转换矩阵得到转换后新的点。

在OpenGL中矩阵的表示方式是一个以为数组。例如44的矩阵中,前4个元素为矩阵第一列,紧接着4个元素为矩阵的第二列,以此类推。在OpenGL中44的矩阵可以表示一个三维坐标系及其原点。例如下图44的转换矩阵,暂时不看最后一行,前三列分别代表了x,y,z轴的方向,在大多数情况下,这三个轴互相垂直,并且其都为单位向量(未出现硕放效果时),最后1列代表了这个坐标系原点坐标。左上角33矩阵可以实现物体的旋转以及缩放,最后一列可实现物体的平移。

在正常的三维坐标系coordinateSystemO中,使用一个4*4矩阵matrix表示一个新的坐标系coordinateSystemN,一个4维向量vecO表示coordinateSystemO中的任意顶点vertex,matrix和vecO相乘得到新的4维向量vecN,vecN则代表顶点转换到新坐标系中对应位置的顶点。

对于已有的三维坐标系coordinateSystemO,在其中可以建立无数个新的三维坐标系coordinateSystemNi(i = 1~n),对于每一个坐标系都可以使用一个唯一的4*4矩阵表示。将这个矩阵分别和表示模型的所有顶点的向量分别相乘,即可将原模型转换到新的坐标系中。

当一个顶点经过多次矩阵变化到新的坐标空间时,也可以先将代表多个坐标系的矩阵进行转换,直接得到代表原始坐标系到最终坐标系转换矩阵。即A*(B*v) == (A*B)*v,但是这里需注意乘法顺序,因为矩阵的乘法只服从乘法结合律,而不服从乘法交换律。

6.3 OpenGL中的坐标空间

模型空间(Model Space)
用于描述物体空间信息的本地坐标空间,通常也称为物体坐标空间(object space)。通常选择物体的重心或者某一特定的点建立3维空间坐标系,并用一系列顶点来描述一个3D模型。坐标原点通常不会在模型外部,物体绕其坐标原点旋转的同时产生平移是没有任何意义的。

世界空间(World Space)
以一个全局的坐标原点为中心建立的坐标系空间。在OpenGL中全局坐标原点是固定的,前文的模型空间都是在世界空间中以一个新的原点建立的3D坐标系,所有的模型都将被放在世界空间中。一些光照以及物理计算执行时都以世界空间为参照。

视图空间(View Space)
和观察者相关的空间,有时也称为相机空间(camera or eye space)。同模型空间类似,视图空间也是基于世界坐标系建立的。其x,y方向表示相机的右侧和上方,其z轴负方向表示相机的镜头朝向。

剪切空间和标准设备空间(Clip and Normalized Device Space)
剪切空间为一个平截椎体空间,由一个负责投影的矩形近平面和一个负责控制的矩形远平面,位于两个平面之间的区域即为剪切空间。 每个位于剪切空间中的顶点都包含4个分量,称为4维齐次坐标,其第四个分量和顶点到摄像机的距离成正比。在执行裁剪的时候,顶点的4个分量都会除以第四个分量w,当w不为1时,其余三个分量都会被放大或者缩小,这种近大远小的效果表现为透视和投影效果。

为了适配不同的设备,OpenGL采用标准设备空间,即其宽和高都为1,只有在真正显示在屏幕上的时候才会计算出每个顶点的真实像素坐标。经过剪切步骤的顶点都会被转化到标准设备空间内。

上一篇下一篇

猜你喜欢

热点阅读