从固定管线到可编程管线:十段代码入门OpenGL

Posted 天元浪子

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了从固定管线到可编程管线:十段代码入门OpenGL相关的知识,希望对你有一定的参考价值。

文章目录

1. 最简单的OpenGL应用程序

有兴趣点进来阅读此文的同学,想必都已经安装了PyOpenGL,如果没有,请运行pip install pyopengl命令立即安装。使用windows的同学,也可以照此安装,不过可能会遇到因为freeglut.dll缺失或版本错误导致的glutInit函数不可用问题。点击“Python模块仓库”下载适合自己的版本,直接安装.whl文件,也许是windows平台上更稳妥的选择。

demo_01.py

#!/usr/bin/env python3

"""最简单的OpenGL应用程序"""

from OpenGL.GL import *                 # 导入核心库GL,该库所有函数均以gl为前缀
from OpenGL.GLUT import *               # 导入工具库GLUT,该库所有函数均以glut为前缀

def draw():
    """绘制模型"""

    glClear(GL_COLOR_BUFFER_BIT)        # 清除缓冲区

    glBegin(GL_TRIANGLES)               # 开始绘制三角形
    glColor(1.0, 0.0, 0.0)              # 设置当前颜色为红色
    glVertex(0.0, 1.0, 0.0)             # 设置第1个顶点
    glColor(0.0, 1.0, 0.0)              # 设置当前颜色为绿色
    glVertex(-1.0, -1.0, 0.0)           # 设置第2个顶点
    glColor(0.0, 0.0, 1.0)              # 设置当前颜色为蓝色
    glVertex(1.0, -1.0, 0.0)            # 设置第3个顶点
    glEnd()                             # 结束绘制三角形

    glFlush()                           # 输出缓冲区

if __name__ == "__main__":
    glutInit()                          # 1. 初始化glut库
    glutCreateWindow('OpenGL Demo')     # 2. 创建glut窗口
    glutDisplayFunc(draw)               # 3. 绑定模型绘制函数
    glutMainLoop()                      # 4. 进入glut主循环

这段代码使用OpenGL的GLUT库构建了一个最简单的OpenGL应用程序——绘制三角形,运行界面截图如下。

最简单的OpenGL应用程序(demo_01.py)

代码前两行导入GL库和GLUT库——前者是OpenGL的核心库,实现绘图功能,后者是不依赖于窗口平台的OpenGL工具库,用于构建窗口程序。尽管OpenGL包含了多个库,最常用的其实只有三个库,除了这段代码用到了GL和GLUT,还有一个实用库GLU,接下来就会用到。每个OpenGL库都有大量的函数,但库和函数命名规则非常合理,每个函数都以小写的库名作为前缀,代码读起来一目了然,非常清晰。

使用GLUT创建OpenGL应用程序非常简单,就像代码中展示的那样,只需要四步——当然,前提是先准备好绘图函数,也就是上面代码中的draw函数。

这段代码中的draw函数演示了线段的绘制流程。OpenGL可以绘制点、线、面等十种基本图元,每种图元的顶点、颜色以及法向量、纹理坐标等,都必须包含在glBegin函数和glEnd函数之间,而glBegin函数的参数就是十种基本图元之一。

OpenGL基本图元速查表

参数说明
GL_POINTS绘制一个或多个顶点,顶点之间是相互独立的
GL_LINES绘制线段,每两个顶点对应一条单独的线段。若顶点数为奇数,则忽略最后一个
GL_LINE_STRIP绘制连续线段,各个顶点依次顺序连接
GL_LINE_LOOP绘制闭合的线段,各个顶点依次顺序连接,最后首尾相接
GL_POLYGON绘制凸多边形,多边形的边缘决不能相交
GL_TRIANGLES绘制一个或多个三角形,每三个顶点对应一个三角形。若顶点个数不是三的倍数,则忽略多余的顶点
GL_TRIANGLE_STRIP绘制连续三角形,每个顶点与其前面的两个顶点构成下一个三角形
GL_TRIANGLE_FAN绘制多个三角形组成的扇形,每个顶点与其前面的顶点和第一个顶点构成下一个三角形
GL_QUADS绘制一个或多个四边形,每四个顶点对应一个四角形。若顶点个数不是四的倍数,则忽略多余的顶点
GL_QUAD_STRIP绘制连续四边形,每一对顶点与其前面的一对顶点构成下一个四角形

借助于图形,更容易理解基本图元的差异和用法。懒得画图了,在网上随便找了一张,顶点顺序也许和读者的习惯并不一致,但在逻辑上是没有问题的。

OpenGL基本图元顶点顺序示意图

举个例子:在glBegin函数和glEnd函数之间有6个顶点,序号分别是0~5。如果绘制独立三角面(GL_TRIANGLES),则顶点(0, 1, 2)构成一个三角形,顶点(3, 4, 5)构成一个三角形,总共2个三角形;如果绘制带状三角面(GL_TRIANGLE_STRIP),则有4个三角形,分别是顶点(0, 1, 2)、顶点(2, 1, 3)、顶点(2, 3, 4)和顶点(4, 3, 5);如果绘制扇面(GL_TRIANGLE_FAN),同样有4个三角形,分别是顶点(0, 1, 2)、顶点(0, 2, 3)、顶点(0, 3, 4)和顶点(0, 4, 5)。

绘制三角形、四边形、多边形时,顶点的顺序决定了哪个面是图元的正面——正反面的意义不仅仅是确定法向量的方向,还为固定管线中的面裁剪和可编程管线中的弃用片元提供了依据。在OpenGL系统中默认逆时针顺序构建的面是正面,使用glFrontFace函数可以重新设置。


2. 视点系统和投影矩阵

运行第一段代码,改变窗口大小及宽高比,仔细观察就会发现三角形的宽高比例会随窗口宽高比例变化而变化,但是 x x x 轴和 y y y 轴的显示区间始终都是(-1, 1), x x x 轴从屏幕的左侧指向右侧, y y y 轴从屏幕的底部指向上方, z z z 轴垂直于屏幕。OpenGL使用右手系坐标,参考下图,不难想象出 z z z 轴的方向是从屏幕里面指向外面的。

右手系坐标示意图

接下来有一个问题需要思考:如何看到第一段代码展示的三角形的背面呢?无论怎样调整眼睛和屏幕的相对关系,看到的始终是平面的2D效果。设想一下,把有深度的屏幕(默认 z z z 轴就是深度轴)视为一个舞台场景,将一架相机放入场景中,改变相机的位置和拍摄角度,不就可以看到想要的效果了吗?

那么,如何定义场景中的这一架相机呢?现实生活中使用相机拍照,大致有以下三个要素。

  1. 相机位置:决定了相机和拍摄目标之间的空间相对关系
  2. 拍摄角度:相机的姿态,包括横置、竖置、平视、俯视、仰视等
  3. 镜头焦距:决定视野宽窄,焦距越小,视野越宽

在3D场景中的相机与之类似,只不过是用相机和拍摄目标之间的距离、相机的方位角和高度角替代了相机位置和拍摄角度,三要素变成了四要素。

  1. 相机和拍摄目标之间的距离(代码中简写为dist)
  2. 方位角(代码中简写为azim)
  3. 高度角(代码中简写为elev)
  4. 水平视野角度(代码中简写为fovy)

在一个OpenGL空间直角坐标系中,点 a a a z z z 轴正半轴上一点,假设相机位置在空间点 p p p 处,点 b b b 为点 p p p x o z xoz xoz 平面上的投影,那么线段 o p op op 的长度就是相机和坐标原点之间的距离dist, ∠ a o b ∠aob aob 即为方位角azim, ∠ p o b ∠pob pob 即为高度角elev。相机绕 y y y 轴逆时针旋转时,方位角正向增加;相机向 y y y 轴正方向升高时,高度角正向增加。

相机的方位角和高度角

至此,一个完整的视点系统就建立起来了。视点系统对应着一个矩阵,相机方位角、高度角以及距离的变化就是改变这个矩阵,这个矩阵叫做视点矩阵(View Matrix)。视点矩阵是玩转OpenGL必须要理解的三个矩阵之一,另外两个是投影矩阵(Projection Matrix)和模型矩阵(Model Matrix),三个矩阵合称MVP矩阵。喜欢篮球或足球的话,很容易记住这个组合——MVP,最有价值球员。

demo_02.py

#!/usr/bin/env python3

"""视点系统和投影矩阵"""

import numpy as np
from OpenGL.GL import *
from OpenGL.GLU import *
from OpenGL.GLUT import *

dist = 5.0                      # 全局变量:相机与坐标原点之间的距离
azim = 0.0                      # 全局变量:方位角
elev = 0.0                      # 全局变量:高度角
fovy = 40.0                     # 全局变量:水平视野角度
near = 2.0                      # 全局变量:最近对焦距离
far = 1000.0                    # 全局变量:最远对焦距离
cam = (0.0, 0.0, 5.0)           # 全局变量:相机位置
csize = (800, 600)              # 全局变量:窗口大小
aspect = csize[0]/csize[1]      # 全局变量:窗口宽高比
mouse_pos = None                # 全局变量:鼠标位置

def click(btn, state, x, y):
    """鼠标按键和滚轮事件函数"""

    global mouse_pos

    if (btn == 0 or btn == 2) and state == 0: # 左键或右键被按下
        mouse_pos = (x, y) # 记录鼠标位置

    glutPostRedisplay() # 更新显示

def drag(x, y):
    """鼠标拖拽事件函数"""

    global mouse_pos, azim, elev, cam

    dx, dy = x-mouse_pos[0], y-mouse_pos[1] # 计算鼠标拖拽距离
    mouse_pos = (x, y) # 更新鼠标位置
    azim = azim - 180*dx/csize[0] # 计算方位角
    elev = elev + 90*dy/csize[1] # 计算高度角

    d = dist * np.cos(np.radians(elev))
    x_cam = d*np.sin(np.radians(azim))
    y_cam = dist*np.sin(np.radians(elev))
    z_cam = d*np.cos(np.radians(azim))
    cam = [x_cam, y_cam, z_cam] # 更新相机位置
 
    glutPostRedisplay() # 更新显示

def reshape(w, h):
    """改变窗口大小事件函数"""

    global csize, aspect    

    csize = (w, h) # 保存窗口大小
    aspect = w/h if h > 0 else 1e4 # 更新窗口宽高比
    glViewport(0, 0, w, h) # 设置视口

    glutPostRedisplay() # 更新显示

def draw():
    """绘制模型"""

    glClear(GL_COLOR_BUFFER_BIT)            # 清除缓冲区

    glMatrixMode(GL_PROJECTION)             # 操作投影矩阵
    glLoadIdentity()                        # 将投影矩阵设置为单位矩阵
    gluPerspective(fovy, aspect, near, far) # 生成透视投影矩阵
    gluLookAt(*cam, *[0,0,0], *[0,1,0])     # 设置视点矩阵

    glBegin(GL_TRIANGLES)                   # 开始绘制三角形
    glColor(1.0, 0.0, 0.0)                  # 设置当前颜色为红色
    glVertex(0.0, 1.0, 0.0)                 # 设置顶点
    glColor(0.0, 1.0, 0.0)                  # 设置当前颜色为绿色
    glVertex(-1.0, -1.0, 0.0)               # 设置顶点
    glColor(0.0, 0.0, 1.0)                  # 设置当前颜色为蓝色
    glVertex(1.0, -1.0, 0.0)                # 设置顶点
    glEnd()                                 # 结束绘制三角形

    glBegin(GL_LINES)                       # 开始绘制线段
    glColor(1.0, 0.0, 1.0)                  # 设置当前颜色为紫色
    glVertex(0.0, 0.0, -1.0)                # 设置线段顶点(z轴负方向)
    glColor(0.0, 0.0, 1.0)                  # 设置当前颜色为蓝色
    glVertex(0.0, 0.0, 1.0)                 # 设置线段顶点(z轴正方向)
    glEnd()                                 # 结束绘制线段

    glFlush()                               # 执行缓冲区指令

if __name__ == "__main__":
    glutInit()                              # 1. 初始化glut库
    glutInitWindowSize(*csize)              # 2.1 设置窗口大小
    glutCreateWindow('OpenGL Demo')         # 2.2 创建glut窗口
    glutDisplayFunc(draw)                   # 3.1 绑定模型绘制函数
    glutReshapeFunc(reshape)                # 3.2 绑定窗口大小改变事件函数
    glutMouseFunc(click)                    # 3.3 绑定鼠标按键
    glutMotionFunc(drag)                    # 3.4 绑定鼠标拖拽事件函数
    glutMainLoop()                          # 4. 进入glut主循环

这段代码依旧是绘制了一个三角形,外加一条和 z z z 轴重合的线段。现在拖拽鼠标改变相机位置就能看到三角形的背面了,并且改变窗口大小时,三角形的宽高比例不再随着窗口的宽高比例的改变而改变。运行界面截图如下。

视点系统和投影矩阵(demo_02.py)

前面说过OpenGL有三个最常用的库,第一段代码用到了GL库和GLUT库,这段代码用到了了第三个库——GLU库中的两个函数,一个是gluPerspective,用来生成透视投影矩阵,一个是gluLookAt,用来设置视点矩阵。

生成投影矩阵需要4个参数,分别是fovy(相机水平视野角度)、aspect(窗口宽高比),以及near(最近对焦距离)和far(最远对焦距离)。near和far是相对于相机的距离,距离相机小于near或者大于far的目标都不会显示在场景中。

生成视点矩阵需要9个参数,分成3组,分别是相机位置、视点坐标系统原点和指向相机上方的单位向量。相机位置不言自明,视点坐标系统原点可以理解为相机的对焦点,代码中直接使用了坐标系原点,即相机的焦点始终对准坐标系原点。指向相机上方的单位向量说的是相机的姿态——是横置还是竖置抑或是旋转180°,这是暂时固定指向 y y y 轴正方向,后续代码会做相应处理。


3. 深度缓冲区和深度测试

在讲述视点矩阵和投影矩阵时,我有意回避了很多复杂的概念,目的是帮助初学者快速理解代码——有了代码,才能在实践中深入理解OpenGL中那些复杂的概念。不过,简化也带来了一些副作用,比如:

  1. 三角形无法遮住其背后的线段,没有实现深度遮挡
  2. 不能缩放视野,无法抵近观察三角形和线段
  3. 使用了很多全局变量,读着吃力,维护起来也很困难,难以实现代码复用

第1个问题中的深度遮挡,简单点说就是前面的模型遮挡后面的模型,复杂点说,对于半透明模型在渲染时还要考虑先后顺序。在3D绘图中实现深度遮挡,只需要开启深度测试并设置深度测试函数的参数就可以了。在此之前,需要先在GLUT的显示模式中启用深度缓冲区。这些工作看似繁琐,其实只需要3行代码。

那么,深度缓冲区究竟是什么?为什么要做深度测试呢?所谓深度缓冲区,就是一块内存区域,存储了屏幕上每个像素点对应的模型上的点和相机的距离,也就是深度值,值越大,离摄像机就越远。渲染像素化后的模型时,要对每个点做深度测试,判断一个点的深度和该点所对应的屏幕上的点的缓存深度谁前谁后,据此决定该点是否被遮挡。深度测试由深度测试函数实现,但需要用户提供测试规则。常用的测试规则见下表。为便于理解,这里我故意混淆了点和片元的概念,待了解了着色器之后,读者自会辨查。

OpenGL深度测试规则速查表

规则说明
GL_ALWAYS总是通过深度测试
GL_NEVER总是不通过深度测试
GL_LESS片元深度值小于缓冲的深度值时通过测试
GL_EQUAL片元深度值等于缓冲区的深度值时通过测试
GL_LEQUAL片元深度值小于等于缓冲区的深度值时通过测试
GL_GREATER片元深度值大于缓冲区的深度值时通过测试
GL_NOTEQUAL片元深度值不等于缓冲区的深度值时通过测试
GL_GEQUAL片元深度值大于等于缓冲区的深度值时通过测试

第2个问题,关于缩放视野,可以用改变相机水平视野角度解决,就像拍照时使用变焦镜头拉近拉远一样,变焦就是改变了镜头的视野角度。如果相机没有变焦功能,那就只能走进或远离拍摄目标,也就是改变相机和拍摄目标之间的距离,同样可以缩放视野。

第3个问题,可以用面向对象编程(OOP)的方式解决。下面的代码基于GLUT库封装了一个3D场景类,启用了深度检测,封装了相机拖拽、视野缩放、相机姿态还原等鼠标操作,还支持自定义高度轴——建筑和空间领域更习惯用 z z z 轴作为高度轴。用户只要在派生类中重写draw函数,就可以方便地复用这段代码了。

demo_03.py

#!/usr/bin/env python3

"""深度缓冲区和深度测试"""

import numpy as np
from OpenGL.GL import *
from OpenGL.GLU import *
from OpenGL.GLUT import *

class BaseScene:
    """基于OpenGl.GLUT的三维场景类"""

    def __init__(self, **kwds):
        """构造函数"""

        self.csize = kwds.get('size', (960, 640))       # 画布分辨率
        self.bg = kwds.get('bg', [0.0, 0.0, 0.0])       # 背景色
        self.haxis = kwds.get('haxis', 'y').lower()     # 高度轴
        self.oecs = kwds.get('oecs', [0.0, 0.0, 0.0])   # 视点坐标系ECS原点
        self.near = kwds.get('near', 2.0)               # 相机与视椎体前端面的距离
        self.far = kwds.get('far', 1000.0)              # 相机与视椎体后端面的距离
        self.fovy = kwds.get('fovy', 40.0)              # 相机水平视野角度
        self.dist = kwds.get('dist', 5.0)               # 相机与ECS原点的距离
        self.azim = kwds.get('azim', 0.0)               # 方位角
        self.elev = kwds.get('elev', 0.0)               # 高度角
 
        self.aspect = self.csize[0]/self.csize[1]       # 画布宽高比
        self.cam = None                                 # 相机位置
        self.up = None                                  # 指向相机上方的单位向量
        self._update_cam_and_up()                       # 计算相机位置和指向相机上方的单位向量

        self.left_down = False                          # 左键按下
        self.mouse_pos = (0, 0)                         # 鼠标位置

        # 保存相机初始姿态(视野、方位角、高度角和距离)
        self.home = 'fovy':self.fovy, 'azim':self.azim, 'elev':self.elev, 'dist':self.dist
 
    def _update_cam_and_up(self, oecs=None, dist=None, azim=None, elev=None):
        """根据当前ECS原点位置、距离、方位角、仰角等参数,重新计算相机位置和up向量"""

        if not oecs is None:
            self.oecs = [*oecs,]
 
        if not dist is None:
            self.dist = dist
 
        if not azim is None:
            self.azim = (azim+180)%360 - 180
 
        if not elev is None:
            self.elev = (elev+180)%360 - 180
 
        up = 1.0 if -90 <= self.elev <= 90 else -1.0
        azim, elev  = np.radians(self.azim), np.radians(self.elev)
        d = self.dist * np.cos(elev)

        if self.haxis == 'z':
            azim -= 0.5 * np.pi
            self.cam = [d*np.cos(azim)+self.oecs[0], d*np.sin(azim)+self.oecs[1], self.dist*np.sin(elev)+self.oecs[2]]
            self.up = [0.0, 0.0, up]
        else:
            self.cam = [d*np.sin(azim)+self.oecs[0], self.dist*np.sin(elev)+self.oecs[1], d*np.cos(azim)+self.oecs[2]]
            self.up = [0.0, up, 0.0]

    def reshape(self, w, h):
        """改变窗口大小事件函数"""
 
        self.csize = (w, h)
        self.aspect = self.csize[0]/self.csize[1] if self.csize以上是关于从固定管线到可编程管线:十段代码入门OpenGL的主要内容,如果未能解决你的问题,请参考以下文章

从固定管线到可编程管线:十段代码入门OpenGL

OpenGL学习随笔-- OpenGL ES 2.0渲染管线

OpenGl固定管线各种存储着色器

OpenGl固定管线各种存储着色器

我的OpenGL学习进阶之旅OpenGL ES 3.0实现了具有可编程着色功能的图形管线

我的OpenGL学习进阶之旅OpenGL ES 3.0实现了具有可编程着色功能的图形管线