OpenGL/OpenGL ES入门: 图像渲染实现以及渲染问题

4,341 阅读10分钟

系列推荐文章:
OpenGL/OpenGL ES入门:图形API以及专业名词解析
OpenGL/OpenGL ES入门:渲染流程以及固定存储着色器
OpenGL/OpenGL ES入门:图像渲染实现以及渲染问题
OpenGL/OpenGL ES入门:基础变换 - 初识向量/矩阵
OpenGL/OpenGL ES入门:纹理初探 - 常用API解析
OpenGL/OpenGL ES入门: 纹理应用 - 纹理坐标及案例解析(金字塔)
OpenGL/OpenGL ES入门: 顶点着色器与片元着色器(OpenGL过渡OpenGL ES)
OpenGL/OpenGL ES入门: GLKit以及API简介
OpenGL/OpenGL ES入门: GLKit使用以及案例

图像渲染的实现

先看用一个平面着色器渲染出的一个甜甜圈

平面着色器渲染甜甜圈效果图

代码实现:

  • main 函数,程序入口。所以OpenGL处理图形、图像都是链式形式,以及基于OpenGL封装的图像处理框架也是链式编程
    gltSetWorkingDirectory(argv[0]);
    
    glutInit(&argc, argv);
   // 初始化窗口
    glutInitDisplayMode(GLUT_DOUBLE | GLUT_RGBA | GLUT_DEPTH | GLUT_STENCIL);
    glutInitWindowSize(800, 600);
    glutCreateWindow("ZB");
    // 注册函数
    glutReshapeFunc(ChangeSize);
    glutSpecialFunc(SpecialKeys);
    glutDisplayFunc(RenderScene);
    
    GLenum err = glewInit();
    if (GLEW_OK != err) {
        fprintf(stderr, "GLEW Error:%s\n", glewGetErrorString(err));
        return 1;
    }
    // 主动触发,准备工作
    SetupRC();
    // 一个无限执行的循环,负责一直处理窗口和操作系统的用户输入等操作
    glutMainLoop();
    return 0;
  • changeSize 通过glutReshapeFunc注册为重塑函数,当第一次创建窗口或屏幕大小发生改变时,会调用该函数调整窗口大小/视口大小
    // 保证高度不能为0
    if (h == 0) {
        h = 1;
    }
    
    // 将视口设置为窗口尺寸
    glViewport(0, 0, w, h);
    // 创建投影矩阵,并将它载入投影矩阵堆栈中
    viewFrustum.SetPerspective(35, float(w)/float(h), 1, 1000);
    projectionMatrix.LoadMatrix(viewFrustum.GetProjectionMatrix());
    
    // 初始化渲染管线
    transformPipeline.SetMatrixStacks(modelViweMatix, projectionMatrix);
  • SetupRC 设置需要渲染图形相关顶点数据、颜色值等,手动在main函数调用
    // 1. 设置背景色
    glClearColor(0.3, 0.3, 0.3, 1);
    
    // 2. 初始化着色器管理器
    shaderManager.InitializeStockShaders();
    
    // 3. 将相机向后移动7个单元,肉眼到物体的距离
    viewFrame.MoveForward(5.0);
    
    // 4. 创建一个甜甜圈
    /**
     void gltMakeTorus(GLTriangleBatch& torusBatch, GLfloat majorRadius, GLfloat minorRadius, GLint numMajor, GLint numMinor);
     参数1: GLTriangleBatch 容器帮助类
     参数2: 外边缘半径
     参数3: 内边缘半径
     参数4、5: 主半径和从半径的细分单元数量
     */
    gltMakeTorus(torusBatch, 1, 0.3, 88, 33);
    
    // 5. 点的大小(方便点填充时,肉眼观察)
    glPointSize(4.0);
  • RenderScene 通过glutDisplayFunc注册为渲染函数。当屏幕发生变化或者开发者主动渲染会调用此函数,用来实现数据->渲染过程
    // 1. 清除窗口和深度缓冲区
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
    
    // 2. 把摄像机矩阵压入模型矩阵中,压栈 -- 存储一个状态
    modelViweMatix.PushMatrix(viewFrame);
    
    // 3. 设置绘图颜色
    GLfloat vRed[] = {1, 0, 0, 1};
    
    // 4. 使用平面着色器
    shaderManager.UseStockShader(GLT_SHADER_FLAT, transformPipeline.GetModelViewProjectionMatrix(), vRed);
    
    // 5. 绘制
    torusBatch.Draw();
    
    // 6. 出栈,绘制完成恢复  出栈 -- 恢复一个状态
    modelViweMatix.PopMatrix();
    
    // 7. 强制执行缓存区
    glutSwapBuffers();

到这里为止,编译运行就能过出现上图所示的效果图。利用的是平面着色器。 相当的low。

下面在此基础上进行酷炫的一波操作。

main函数中注册了一个函数SpecialKeys,顾名思义,特殊键位,这里控制的是上下左右键位

    // 1. 判断方向
    if (key == GLUT_KEY_UP) {
        // 2. 根据方向调整观察者位置
        // 参数1: 旋转的弧度
        // 参数2、3、4:表示绕哪个轴进行旋转
        viewFrame.RotateWorld(m3dDegToRad(-5), 1, 0, 0);
    }
    if (key == GLUT_KEY_DOWN) {
        viewFrame.RotateWorld(m3dDegToRad(5), 1, 0, 0);
    }
    if (key == GLUT_KEY_LEFT) {
        viewFrame.RotateWorld(m3dDegToRad(-5), 0, 1, 0);
    }
    if (key == GLUT_KEY_RIGHT) {
        viewFrame.RotateWorld(m3dDegToRad(5), 0, 1, 0);
    }
    // 3. 重新刷新
    glutPostRedisplay();

看实现效果

能够旋转的甜甜圈

在来一波更真实的操作,我们使用默认光源着色器来实现

    // 1. 清除窗口和深度缓冲区
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
    
    // 2. 把摄像机矩阵压入模型矩阵中
    modelViweMatix.PushMatrix(viewFrame);
    
    // 3. 设置绘图颜色
    GLfloat vRed[] = {1, 0, 0, 1};
    
    // 4. 使用平面着色器
//    shaderManager.UseStockShader(GLT_SHADER_FLAT, transformPipeline.GetModelViewProjectionMatrix(), vRed);
    
    // 4.1 使用默认光源着色器
    // 通过光源、阴影效果跟体现立体效果
    // 参数1:GLT_SHADER_DEFAULT_LIGHT 默认光源着色器
    // 参数2:模型视图矩阵
    // 参数3:投影矩阵
    // 参数4:基本颜色值
    shaderManager.UseStockShader(GLT_SHADER_DEFAULT_LIGHT, transformPipeline.GetModelViewMatrix(), transformPipeline.GetProjectionMatrix(), vRed);
    
    // 5. 绘制
    torusBatch.Draw();
    
    // 6. 出栈,绘制完成恢复
    modelViweMatix.PopMatrix();
    
    // 7. 强制执行缓存区
    glutSwapBuffers();

效果图如下:

未正背面剔除的渲染

可以看出,我们的渲染出了问题。

问题分析

在使用默认光源着色器时,由于产生了光照,有光照的一面,按照原本的颜色显示,而背光面,则是黑暗的,我们看不见的。其实很好理解,太阳光照地球,迎光面是白天,背光面是黑夜。

在绘制3D场景的时候,我们需要决定哪些部分是对观察者可见的,或者哪些部分是对观察者不可见的,对于不可见的部分,应该及早丢弃。例如在一个不透明的墙壁后,就不应该有渲染,这种情况叫做隐藏面消除

下面讨论一下解决这个问题的方案。

解决问题的方案

油画算法

先绘制场景中离观察者较远的物体,在绘制较近的物体,如下图

油画算法

绘制顺序依次是红、黄、灰,这样的话按序渲染能过解决隐藏面消除的问题。

但是随之而来的会有一些不好的问题出现

  • 效率很低,重叠部分会进行多次绘制渲染,浪费资源
  • 对于某些存在场景,无法区别远近顺序的,无法用该方法解决问题,如下图

无法区别远近

正背面剔除

首先需要确定一个问题,任何平面都有2个面,正面/背面,意味着你一个时刻只能看到一面。

一个立方体图形,从任何一个方向去观察,最多可以看到3个面,意味着其他看不到的面,我们不需要去绘制它,如果能以某种方式去丢弃这部分数据,OpenGL在渲染的性能即可提高50%。

没错,OpenGL能够区别正面和背面,通过分析顶点数据的顺序

OpenGL区别正背面

正面/背面区分

  • 正面:按照逆时针顶点链接顺序的三角形面
  • 背面:按照顺时针顶点连接顺序的三角形面

立方体中的正背面

立方体中的正背面

分析:

  • 左侧三角形顶点顺序为:1->2->3; 右侧三角形的顶点顺序为:1->2->3
  • 当观察者在右侧时,则右边的三角形方向为逆时针方向为正面,而左侧的三角形为顺时针则为反面
  • 当观察者在左侧时,则左边的三⻆形方向为逆时针⽅方向为正面,⽽右侧的三角形为顺时针则为背面

总结: 正面和背面是由三角形的顶点定义顺序和观察者方向共同决定的,随着观察者的角度方向的改变,正面背面也会跟着改变

相关代码

// 开启表面剔除(默认背面剔除)
void glEnable(GL_CULL_FACE);

// 关闭表面剔除(默认背面剔除)
void glDisable(GL_CULL_FACE);

// 用户选择剔除那个面(即可自定义剔除,默认为正面)
void glCullFace(GLenum mode);
mode参数为:GL_FRONT, GL_BACK, GL_FRONT_AND_BACK, 默认为GL_BACK

// 用户也可以指定正面
void glFrontFace(GLenum mode);
mode参数为:GL_CW, GL_CCW, 默认为GL_CCW

// 剔除正面实现
glCullFace(GL_BACK);
glFrontFace(GL_CW);
或
glCullface(GL_FRONT);

具体代码实现

    // 1. 清除窗口和深度缓冲区
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
    
    // 开启正背面剔除
    glEnable(GL_CULL_FACE);
    glFrontFace(GL_CCW);
    glCullFace(GL_BACK);
    
    // 2. 把摄像机矩阵压入模型矩阵中
    modelViweMatix.PushMatrix(viewFrame);
    
    // 3. 设置绘图颜色
    GLfloat vRed[] = {1, 0, 0, 1};
    
    // 后面代码和上面一样,不再重复

实现效果如下图:

未进行深度测试的甜甜圈

可以看到,之前的问题已经解决了,可是又面临了一个尴尬的问题,这个甜甜圈貌似有个很大的缺口,了解过图形渲染的读者肯定知道,这是深度问题,下面来了解一下。

深度

深度就是该像素点在3D世界中距离摄像机的距离,也就是Z值。
深度缓冲区就是一块内存区域,专门存储着每个像素点(绘制在屏幕上的)深度值Z。Z越大,则距离屏幕越远。

那么为什么需要深度缓冲区?
在不实用深度测试的时候,如果我们先绘制一个距离比较近的物体,在绘制距离远的物体,则距离远的位图因为后绘制,会把距离近的物体覆盖掉。有了深度缓冲区后,绘制物体的顺序就不那么重要了。上面出现的大缺口,也就是这个问题造成的。

实际上,只要存在深度缓冲区,OpenGL都会把像素的深度值写入到缓冲区中,除非调用glDepthMask(GL_FALSE)来禁止写入。

深度测试
深度缓冲区和颜色缓冲区是对应的。颜色缓冲区存储像素的颜色信息,而深度缓冲区存储像素的深度信息。在决定是否绘制一个物体表面时,首先要将表面对应的像素的深度值与当前深度缓冲区中的值进行比较,如果大于深度缓冲区的值,则丢弃这部分,否则利用这个像素对应的深度值和颜色值,分别更新深度缓冲区和颜色缓冲区。这个过程称为深度测试

相关代码

// 开启深度测试
glEnable(GL_DEPTH_TEST);

// 在绘制场景前,清除颜色缓冲区和深度缓冲区
glClearColor(0, 0, 0, 1);
glClear(GL_COLOR_BUFFER_BIT | GL_GEPTH_BUFFER_BIT);

清除深度缓冲区默认值为1.0,表示最大的深度值,深度值的范围为(0,1)之间。值越小表示越靠近观察者,反正表示距离观察者越远。

下面有关深度测试的判断式

指定深度测试判断模式
void glDepthFunc(GLEnum mode);
打开/阻断 深度缓冲区写入
void glDepthMask(GKBool value);
value : GL_TURE 开启写入 GL_FALSE 关闭写入

深度测试判断模式

最终的实现效果如下:

最终效果图

ZFighting闪烁问题

为什么会出现ZFighting闪烁问题

因为开启深度测试后,OpenGL就不会去绘制模型被遮挡的部分,这样实现现实更加真实,但是由于深度缓冲区精度的限制,对于深度相差无几的情况下,OpenGL就可能出现不能正确判断两者深度值,会导致深度测试的结果不可预测,现实出来的现象会交错闪烁。

深度相差无几

解决方式

  • 第一步:启用Polygon Offset方式解决
    让深度值之间产生间隔,可以理解为在执行深度测试前,将立方体的深度值做一些细微的增加,于是就能将重叠的2个图形深度值之间有所区分。
// 启用Polygon Offset方式
glEnable(GL_POLYGON_OFFSET_FILL);

参数列表:
GL_POLYGON_OFFSET_POINT 对应光栅化模式:GL_POINT
GL_POLYGON_OFFSET_LINE 对应光栅化模式:GL_LINE
GL_POLYGON_OFFSET_FILL 对应光栅化模式:GL_FILL

  • 第二步:指定偏移量

    • 通过glPolygon Offset 来指定. glPolygon Offset需要2个参数: factor , units.
    • 每个Fragment 的深度值都会增加如下所示的偏移量:
      Offset = ( m * factor ) + ( r * units);
      m : 多边形的深度的斜率的最大值,理解一个多边形越是与近裁剪⾯平行,m就越接近于0.
      r : 能产生于窗口坐标系的深度值中可分辨的差异最小值.r是由具体是由具体OpenGL平台指定的一个常量.
    • 一个⼤于0的Offset会把模型推到离你(摄像机)更远的位置,相应的⼀个小于0的Offset 会把模型拉近
    • 一般⽽言,只需要将-1.0 和 -1 这样简单赋值给glPolygon Offset 基本可以满⾜足需求.
  • 第三步:关闭Polygon Offset

glDisable(GL_POLYGON_OFFSET_FILL);

OK,到此为止,我们完美的把这个甜甜圈给渲染出来了。上面遇到的一些问题也得已解决。