OpenGL Basic

Last Updated on 2019年8月2日

  • 学习现代OpenGL基本上可以分为两个方面: 一方面是熟悉OpenGL的API,另一方面是GLSL语言. 总的来说,OpenGL的API及运行框架是比较简单的,GLSL类似C,虽然语法简单,但是基本所有shading(视觉效果)都要用GLSL写,这个问题的范畴就很大了.本文的重点放在OpenGL API上, 尽量仅使用简单的GLSL着色器.
  • LearnOpenGL CN是一个理论和实践都有阐述的教程,比较适合从0开始快速入门.
  • OpenGL step by step是一个更加专注于代码及实例的教程,还有极客学院翻译版
  • Megabyte OpenGL Tutorial对有一定图形编程基础的开发者而言更简明扼要.

OpenGL的运行模型

  • 从逻辑上看, OpenGL 的运行是基于OpenGL Context的, 可以把OpenGL Context看成一个制图状态机, 我们只要按照它的spec使用它就行.
    • OpenGL Context并不直接作为参数用在API中,可以为每个线程指定一个唯一的”激活”Context,我们在使用API时,都是在操作”激活”的Context
    • 注意: OpenGL并不负责把渲染结果显示出来,显示工作是由GUI库完成的. 一般而言, 支持OpenGL的GUI库会负责创建及维护 OpenGL Context.
    • 注意: OpenGL 的一部分API需要手动查询函数入口.(类似于手动用dlopen载入动态库)

创建/管理 OpenGL Context 的流程因平台而异, 但总的来说,这些实现细节和”如何使用OpenGL API”关系不大.大部分时候都可以完全交给GUI库处理

由于一些历史原因, OpenGL1.1 之前的API都是在libGL中的,可以直接静态链接到程序中, 如果只使用这部分API,其实可以不需要进行动态载入,直接使用gl.h并链接libgl即可.

  • 本文将使用GLFW窗口库和GLAD载入库
    • GLFW负责创建窗口,维护OpenGL Context, 以及和OpenGL Context交换数据. 我们仅仅是通过OpenGL API操作OpenGL Context
    • GLAD载入库负责从OpenGL Context中获得函数入口. 一般而言, GLAD这样的载入库是把所有OpenGL函数都声明成同名的函数指针, 从而让我们在编译期可以使用它们产生函数调用.载入库在运行期为这些函数指针赋值
  • OpenGL的API是handle-based风格的,我们并不会直接使用OpenGL内部的各种对象,每次在OpenGL Context上启动渲染将渲染一个模型. 连续渲染若干个模型就可以得到一个”场景”(当然,你也可以把场景内的所有东西放在同一个”超大模型”里,然后一次渲染完成.)
    • 一次渲染只能渲染一个模型,而每个模型都对应了很多相关联的handle,所以在渲染特定模型前,常常需要切换到对应的一组handle.
    • 不同批次的渲染之间相当孤立, 后续的渲染几乎不能得到以前的模型信息, 所以往往需要使用各种奇技淫巧来实现所谓的”环境”效果, 使得场景中的各个实体彼此能够交互.典型的一个例子就是镜子: 我们现在要渲染一个镜子模型,那么不可避免的,我们必须知道之前(甚至之后)所有渲染过模型的信息,才能根据”CameraPos+反射”来计算镜子中各个点处的颜色值, 在OpenGL工作流程中,很难有直观的方法完成这些工作.
  • OpenGL只负责启动渲染,并控制少量的渲染参数, 大部分的渲染细节是在着色器程序中实现的. 除非经过刻意的设计,大部分模型的着色器都是不一样的,可以近似认为每个模型都有其特定的着色器
  • 从客观上讲,OpenGL只支持在$(x,y) \in {[ – 1,1]^2},z \in [0,1]$的欧氏空间中向$z=0$做平行正投影,所有渲染都是围绕这个底层特性展开的.
  • 从逻辑上看,OPENGL系统要求我么总是逆着某个矢量进行观测,称该矢量为”摄像机矢量”

Texture

  • Texutre的使用方法概述.
    • OpenGL Context内部有若干个全局的纹理单元,每个纹理单元都有自己的ID.
    • 在Host端,需要把创建的纹理对象绑定到某一个ID的纹理单元上,然后再通过uniform的IO把这个ID值更新到着色器中.
    • 着色器根据纹理单元的ID进行采样读取, 例如texture(uint_id, texture_pos)就是从uint_id号纹理单元内的纹理进行采样.
    • 这里的变量类型为uniform texture unit_id;vec2 texture_pos
    • texture_pos一般作为原始顶点数据输入,在光栅化阶段自动为每个片段插值出对应坐标.

模板测试和深度测试.

  • OpenGL使用了三个缓冲,”颜色缓冲”,”深度缓冲”,”模板缓冲”.
  • 尽管OpenGL中称模板测试为Stencil Test,但是完全可以把模板作为一个像素图状的mask来理解.
  • 模板缓冲对应的mask不是一个简单的二进制掩模,每个像素值都可以是24位整数,这可以帮助我们实现很多特性.
  • 片段着色器输出的每一个片段都会先通过”模板测试”过滤,再通过”深度测试”过滤
  • 深度测试的功能比较单一,模板测试则灵活的多,很多高级的功能都需要通过模板测试来实现.

混合

  • 在通过模板测试和深度测试后
    • 若没有开启Blend,则新的片段将直接替换”颜色缓冲”中对应位置的已有片段.
    • 若开启了Blend,则新的片段将按照用户设置的规则和”颜色缓冲”中的片段进行”混合”

面剔除

  • OpenGL中,我们可以为每一个片段定义一个正向,这个正向可以在面剔除/着色器中服务于我们.例如,在通过TRIANGLE绘制图形时,对于用于构成三角形的三个顶点A,B,C
    • 使用glFrontFace(GL_CCW),则AB x BC定义为这个三角面片的”正向”.(默认)
    • 使用glFrontFace(GL_CW),则-AB x BC定义为这个三角面片的”正向”.
  • 在未开启面剔除时,进入着色器的片段中,既有面朝相机的,也有背对相机的,可以通过bool gl_Frontfacing这个内置变量读取片段的朝向.
  • 设片段到摄像机的矢量为p,片段的正向为v,那么如果p的和v的夹角超过90度,则OpenGL认为这个片段是”背对”摄像机,否则,这个片段是”正对”摄像机的. OpenGL可以通过glCullFace(GL_FRONT)(glCullFace(GL_BACK))来剔除正对相机(背对相机)的片段. 面剔除在光栅化之前的图元阶段就会执行,所以被面剔除的片段不会进入片段着色器.
    • 注意, 这里面的v是由用户通过GL_CCWGL_CW决定的,一定要确定设定的正向符合我们的预期.
  • 面剔除的典型应用场景是加速封闭三维实体的渲染过程, 对于封闭的三围实体,如果我们定义每个片段的正向朝外,那么,只要是”背对”摄像机的片段,我们一定看不见. 它们可以被放心的剔除掉.(正对我们的片段也有可能被挡住而看不见,这种片段不能被剔除掉,只可以被深度缓冲过滤.)
  • 在有些摄像机实现中,会实现正面剔除的功能. 例如,如果盒子模型向外为正向,当我们离盒子足够近的时候, 就把这个盒子的正面剔除掉,从而让我们能看到屋子里的情况.

Framebuffer

  • 颜色缓冲 + 深度缓冲 + 模板缓冲, 三者合一称为可编程的Framebuffer(帧缓冲) , 我们可以用自定义的Framebuffer来替代默认的帧缓冲.
    • 在大部分的驱动实现中,模板缓冲一般不是独立的,所以要么只有深度缓冲,要么只有”深度+模板”二合一缓冲,不能使用独立的模板缓冲.
  • Framebuffer 的Device端数据容器有两种, 一种是Texuture,另一种是Renderbuffer.
    • 当使用Texture作为Framebuffer的数据容器时, Framebuffer的内容可以可以被Host端以及着色器访问.
    • 使用Renderbuffer作为Framebuffer的数据容器时, Framebuffer的内容只能供OpenGL自身使用.
    • Renderbuffer相对Texture而言,是一种更加原始的底层数据,使用它进行渲染性能更好,例如手动实现”双缓冲”时,就应该用这种数据容器. Texture则更有利于开发者进行编程处理.
  • 创建和管理Framebuffer相对其他OpenGL对象要更加复杂一些, 因为所有的底层容器都需要我们手动管理.

Batch Draw

  • OpenGL支持一种称为glDrawXXXInstanced的绘制模式,它允许我们把同一个模型重复绘制N次.
  • 在重复的绘制过程中,顶点着色器的内置变量gl_InstanceID会逐步递增.
  • 和Batch绘制一同使用的技术一般是glVertexAttribDivisor(vao,k),它将使得缓冲中的数据和InstanceID绑定,而不是和顶点绑定,InstanceID每更新k次时,将着色器中对应position的数据更新一次.

抗锯齿

  • OpenGL自带了一个基于MSAA的抗锯齿功能,我们可以手动启用它
    • MSAA可以近似为特殊的超分辨率渲染,也就是在每个像素内再划分出更多的子像素进行渲染.(也就是所谓的”对一个像素进行多次不同位置的采样”)
    • 对于k阶多重采样,最终每个像素位置将输出k个颜色值,显示系统负责把多重采样的颜色缓冲输出为最终颜色(一般就是直接取均值)
    • 使用MSAA显然会带来更大的开销,且framebuffer的颜色缓冲尺寸也需要扩大k倍

着色器

“片段”概念的理解: CG中一般是把所有三位物体都实现为空心的壳装物, 那么我们用光栅去切割这些壳装物时,自然就得到了一个个”薄片”,也就是片段了. 尽管被切割了,片段仍然是位于三维空间的,并不是二维概念下的pixel(像素), 所有”x,y”相同的片段将参与对应像素的形成.

  • 着色器是一系列串行执行的小程序, 基本可以分为两个阶段:”顶点阶段”和”片段阶段”
    • 从简单的角度看,我们可以认为 “原始顶点数据 -> 顶点处理阶段 -> 顶点数据 -> 光栅化 -> 原始片段数据-> 片段处理阶段 -> 颜色数据 -> 模板测试-> 深度测试 -> 混合”
    • 顶点着色器的输入是某个顶点的原始数据,这个原始数据由我们编程时给出.(由于我们不能直接控制数据的传输,所以需要在Host端通过Location描述数据,再在着色器中根据Location解析数据)
    • 片段着色器的输入是某个片段的原始数据,这个原始数据主要是由之前的着色器给出的, 例如,可以在片段着色器中手动out一个值,这个值就可以被后续的着色器使用了.
  • 顶点着色器:必须输出一个内置的gl_Position,从整体看,我们需要把待显示的部分放在$(x,y) \in {[ – 1,1]^2},z \in [0,1]$的欧氏空间中,超出部分会被裁减掉.
    • gl_Position是一个齐次坐标, OpenGL会自动对其做齐次除法以获得三维坐标.如果需要使用三维坐标值,我们也可以显式的在顶点着色器中做自己做齐次除法.
    • OpenGL要求顶点着色器输出的gl_Position中,z为”深度值”.这个深度值需要随着点距摄像机的距离增大而增大(不要求线性关系).
    • gl_Position的输出最终将决定片段着色器中的gl_FragCoord
  • 片段上绑定的数据都是根据顶点数据插值而来的.顶点着色器输出的数据如果没有被后续着色器使用,会在链接阶段自动被丢弃,与其相关的计算也会被优化掉.
    • 片段着色器中可以手动的调用discard丢弃当前片段
  • 几何着色器:几何着色器的输入是已经初步组装好的图元,输出的图元会在后续进行光栅化.
    • 几何着色器是唯一能得到模型”局部”信息的着色器.
    • 几何着色器常用于为已有的图元添加更多细节.例如,若输入的是一个三角形图元,那么我们可以根据三角形的三个顶点绘制出它的法矢线段,然后把原来的三角图元和法线段都输出出去, 就可以得到整个模型的法线分布效果了.简单的粒子效果也可以在几何着色器中完成,我们可以为输入的每一个图元的坐标额外偏移一个随机值.

最好不要在顶点着色器中对矢量进行变换, 错切,非等比拉伸都会导致矢量”变形”,尤其是法矢这种可能与顶点绑定的数据.

常用着色器内置变量

  • gl_VertexID,类似于CUDA的index
  • gl_FrontFacing 片段是否面朝摄像机
  • gl_Position,gl_FragCoord,gl_FragDepth
    • gl_FragDepth也是片段着色器的一个输出,它取值在0.0 - 1.0,默认就是gl_FragCoord.z
    • 如果我们不在片段着色器中设置gl_FragDepth,那么就有可能自动激活称为”提前深度测试”的技术,驱动将会在片段着色器前剔除深度值大于深度缓冲的片段,从而降低后续的无用开销.
  • 除了与顶点绑定的数据外,还有uniform数据类型.这种数据不与顶点/片段绑定,相当于是传统概念上的”全局变量”,可以直接在host端赋值, texture就是典型的必须要用uniform的类型.

OPENGL观察系统简述

  • 由于历史原因,在定义模型/世界阶段,我们按照右手系进行描述(Z轴正方向穿出屏幕).而在顶点着色器输出gl_Position之后,世界需要按左手坐标系描述.
    • 按照这样的惯例, 在顶点着色器中,一般必须有z=-1*z这样的操作.(GLM生成的view矩阵会自动做这种工作,所以我们不必自己去做z=-1*z)
    • 为了便于理解,我们可以在观察箱确定并旋转好后,把世界坐标中的标准Box按住不放,把z轴掉个方向就可以了.
  • 由于深度值实际是在左手坐标系中描述的,所以深度值越大,自然就离镜头越远.
    • 在顶点着色器中,z值一般是按照非线性的方式转换为深度值,是为了使得在离摄像机越近时,能有更高的深度值精度.
  • 片段着色器中的gl_FragCoordx,y是屏幕坐标,z等于gl_Position.z.
  • glViewPort这个函数对渲染流程没有什么意义, 它仅仅是在光栅化阶段为片段分配”屏幕坐标”. 而GUI库将根据片段的”屏幕坐标”来绘制图像.

光照基础

  • 三个常见的光物理现象: 对于物体表面的一个微元面
    • Diffuse: 光源在打到物体表面后,会向各个方向反射出一个均匀的分量
    • Specular: 光源在打到物体表面后,会按镜面规则进行反射, 这种光是高度有向的,在恰好反射入人眼时强度最大,稍微偏离后迅速减弱.
    • Ambient: 物体所在的环境也会反射光,最终照在物体上.
  • Phong光照: 组合了Diffuse,Specular,Ambient,并进行适度简化的光照模型,需要在片段着色器实现(在顶点着色器实现的称为Gouraud光照,由于顶点着色器对矢量的插值,最终的效果一般比较差.)
    • 可以通过预渲染来估计环境光的强度Ia,或者直接设为一个简单的常量,若微元面对环境光的反射率为Ra,则Ia*Ra就是在环境光下呈现的颜色.
    • 直接根据Lambert reflection定律Id*Rd*cos(theta)估算漫反射的强度.theta是入射角,Rd是在漫反射下的反射率.
    • Specular反射简化为: 反射光和人眼的夹角越小,则反射光的强度越大,也就是说,射入人眼的光强度正比于cos<frag_to_eye,reflection>.在实践中,为了实现高光效果,一般增加一个shinness参数,认为镜面反射的强度按照Is*Rs*cos(alpha)^shinness来计算,Is是入射光强度,alpha是反射光和视线的夹角,shinness称为反光度.
    • Is*Rs*cos(alpha)^shinness+Id*Rd*cos(theta)+Ia*Ra就是片段最终输出的颜色
    • 在实践中,我们可以简化的认为每一个光源都对应了各自唯一的Is,Id,Ia.
  • 实践中,光源的属性一般还会额外分类为”平行光”,”点光源”,”聚光灯”
    • 平行光是无衰减的光,代表了太阳这样的东西.
    • 点光源一般认为是平方衰减的.
    • 聚光灯是带有作用范围的点光源,在超出一定角度后,会极速衰减.

QT 5.7 下的OpenGL开发概述

这一部分算是Qt中开发OpenGL中的入门,看完之后,可以选择深入的看class QOpenGLContext的文档,之后就能直接看QT官方的文档:OpenGL Window Example.另外,在Examples\opengl目录下还包含了诸多没有文档的OpenGL Sample可以参考.

主要结论:

  • QT完整的支持OpenGL开发.
  • QT中通过QOpenGLContext来创建及管理OpenGL Context,且提供了一系列强大的API,可以对其创建的OpenGL Context进行精细的控制.
    • QT内提供了两个便捷类,分别为QOpenGLWindowQOpenGLWidget,这二者内部都持有一个QOpenGLContext对象.
    • 能够显示OpenGL渲染结果的底层基础是QSurface::OpenGLSurface,它在同一时刻只能和对应线程的一个OpenGL Context绑定.
  • QT的OpenGL Context可以完整的支持各种版本的OpenGL API,创建及管理OpenGL Context的部分是通用的,但是之后的函数链接及调用上可能出现分歧.
    • 你可以使用各种机制来完成Function Loading,例如流行的开源项目GLAD.
    • 你也可以使用QT内部预制的名为QOpenGLFunction_X_X_Core的类的实例来完成Function Loading,其缺陷是对OpenGL扩展的支持不太好.
    • 注意,QOpenGLFunctions这个类是为内置快捷OpenGL ES组件服务的,而不是最新的OpenGL
  • 其他:
    • Qt 内预置了QOpenGLPaintDevice, 可以通过OpenGL为QPianter的绘制加速.
    • Qt 内预置了以OpenGL ES(OpenGL2.0)为中心的一系列组件,对OpenGL做出了很高的抽象.(目前只有OpenGL ES可以做到全平台)
      • 这些组件并不保证向后的拓展性,可能并不能使用最新的特性.虽然损失了一定的自由度和先进性,但是更适用于跨平台的快速开发.
      • 如果你是深度的桌面OpenGL开发者,那么你只需要仔细学习QOpenGLContext,再初步了解QT的GUI系统工作流程即可.
    • 使用QGLxxxAPI的代码都不具备参考价值,这些API只是QT5为了兼容QT4代码而保留的,将会被QT逐渐移除.
    • 使用内置组件QOpenGLxxx的代码可以视为针对QOpenGLFunction_2_0的封装.

细节:

  • QOpenGLContext类的setFormat(),create(),makeCurrent(),swapBuffers()是你至少应当知道的API
  • 由于QT是多线程GUI系统,所以对一个Surface,一定要关注它是和当前线程的哪个Context绑定的.
    • glcontext.swapBuffers()可以在SurfaceContext之间交换缓冲数据.
    • Surface对应的代码通常是在一个独立的线程中运行.
      • Surface对应代码的作用域内调用glcontext.makeCurrent()就可以完成绑定工作.
      • 习惯上,总应该在渲染开始及glcontext.swapBuffers()前,调用glcontext.makeCurrent(),确保该Context绑定到当前线程
  • 不能直接跨线程的调用Context的相关API,但是可以通过moveToThread()在线程间转移Context.
  • 虽然大部分系统使用0作为默认缓冲的ID,但是OpenGL推荐使用ctx->defaultFramebufferObject()来获得ctx的默认缓冲ID.
  • 如果使用QOpenGLFunction_X_X_Core这样的类来完成function loading
    • 创建对象后,通过调用func->initializeOpenGLFunctions()来完成函数的动态链接,之后通过func->glBindBuffer()这样的形式来产生调用`
    • 可以通过将QOpenGLFunction_X_Xprotect继承来获得与原生OpenGL近似的调用体验.
    • 使用这些类后,会间接的链接opengl库,不必再手动链接.(如opengl32.lib)

QT OpenGL 中的程序控制流

  • QT中,如果我们以”game-loop”型的风格进行渲染,那么必须在game-loop中调用QCoreApplication::processEvents()来处理GUI相关的事件,避免GUI界面卡死.
  • 从另一方面看,如果我们将渲染一帧视为一个事件,那么就可以通过事件系统来触发渲染.
    • 基于事件的”render-loop”风格.在单次渲染完成后,发出”call_render”事件,以再次进入render().(可以参考OpenGL Window Example)
    • QTimerEvent定时事件+事件响应风格,通过定时器定时触发事件.(当然,QBasicTimer+信号槽是等价的)
    • 对于一些低频的静态任务(例如通过鼠标扭转观察角度),那么通过事件系统绘制更加节约资源.只需要响应鼠标事件/重绘事件等即可.