OpenGL绘制甜甜圈引发的思考:正背面剔除、深度测试、多边形偏移

499 阅读11分钟

前言OpenGL环境配置!

绘制甜甜圈

废话不多说,直接main函数上上代码



#include "GLTools.h"
#include <GLUT/GLUT.h>
#include "GLFrustum.h"
#include "GLMatrixStack.h"
#include "GLGeometryTransform.h"
#include "GLFrame.h"

//投影矩阵,设置图元绘制时的投影方式
GLFrustum viewFrustum;

//投影矩阵
GLMatrixStack projectionMatrix;
//模型视图矩阵
GLMatrixStack modelViewMatrix;
//变换管道,存储模型视图/投影/模型视图投影矩阵
GLGeometryTransform transformPipeline;
//设置角色帧,作为观查者
GLFrame viewFrame;
//固定着色器管理者
GLShaderManager shaderManager;
//
GLTriangleBatch torusBatch;

//渲染场景
void RenderScene(){
    //清空颜色缓冲区和深度缓冲区:如果不清空,上一次绘制的内容将被保留在缓冲区中,可以关闭这一句代码运行看看效果
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
    //把观察者压入模型矩阵堆栈
    modelViewMatrix.PushMatrix(viewFrame);
    //设置绘图颜色
    GLfloat vRed[] = {1.0,0.0,0.0,1.0};
    //使用平面着色器
//    shaderManager.UseStockShader(GLT_SHADER_FLAT,transformPipeline.GetModelViewProjectionMatrix(),vRed);
    
    //使用默认光源着色器
   /**
    //参数1:GLT_SHADER_DEFAULT_LIGHT 默认光源着色器
    //参数2:模型视图矩阵
    //参数3:投影矩阵
    //参数4:基本颜色值
    */ shaderManager.UseStockShader(GLT_SHADER_DEFAULT_LIGHT,transformPipeline.GetModelViewMatrix(),transformPipeline.GetProjectionMatrix(),vRed);
    //绘制
    torusBatch.Draw();
    //出栈,绘制完成后要恢复
    modelViewMatrix.PopMatrix();
    //交换缓冲区
    glutSwapBuffers();
}
void SetupRC(){
    //设置背景颜色
    glClearColor(0.3f, 0.3f, 0.3f, 1.0f);
    //初始化固定着色器管理者
    shaderManager.InitializeStockShaders();
    //将相机后移7个单元,肉眼到物体之间的距离
    viewFrame.MoveForward(7.0);
    //创建一个甜甜圈,将后面的数据存在torusBatch中
    /**
     //参数1:GLTriangleBatch 容器帮助类
     //参数2:外边缘半径
     //参数3:内边缘半径
     //参数4、5:主半径和从半径的细分单元数量,一般是两倍的关系
     */
    gltMakeTorus(torusBatch, 1.0f, 0.3f, 52, 26);
    //点的大小
    glPointSize(1.0f);
}
void SpecialKeys(int key,int x,int y){
    if (key == GLUT_KEY_UP) {
        //上下是绕着x转旋转5度
        viewFrame.RotateWorld(m3dDegToRad(-5.0), 1.0f, 0.0f, 0.0f);
    }
    if (key == GLUT_KEY_DOWN) {
        viewFrame.RotateWorld(m3dDegToRad(5.0), 1.0f, 0.0f, 0.0f);
    }
    if (key == GLUT_KEY_LEFT) {
        viewFrame.RotateWorld(m3dDegToRad(-5.0), 0.0, 1.0, 0.0);
    }
    if (key == GLUT_KEY_RIGHT) {
        viewFrame.RotateWorld(m3dDegToRad(5.0), 0.0, 1.0f, 0.0);
    }

    //重新渲染
    glutPostRedisplay();

}
//键位设置,通过不同的键位对其进行设置
//控制Camera的移动,从而改变视口
void ChangeSize(int w, int h){
    //防止h变为0
    if (h==0) {
        h = 1;
    }
    //设置窗口大小
    glViewport(0, 0, w, h);
    //创建透视投影方式
    viewFrustum.SetPerspective(35.0, float(w)/float(h), 1.0f, 500.0f);
    //将投影矩阵投影到投影矩阵堆栈中
    projectionMatrix.LoadMatrix(viewFrustum.GetProjectionMatrix());
    //初始化渲染管线,设置变换管线来将两个矩阵设置到矩阵白堆栈中
    transformPipeline.SetMatrixStacks(modelViewMatrix, projectionMatrix);

}

int main(int argc, char * argv[]){
    
    ////开辟一个工作空间
    gltSetWorkingDirectory(argv[0]);
    //初始化
    glutInit(&argc, argv);
    //申请一个颜色缓存区、深度缓存区、双缓存区、模型缓存区
    glutInitDisplayMode(GLUT_RGBA | GLUT_DOUBLE | GLUT_DEPTH | GLUT_STENCIL);
    //设置window的尺寸
    glutInitWindowSize(800, 600);
    //设置window的名称
    glutCreateWindow("甜甜圈");
    //注册回调函数
    //改变window尺寸的函数
    glutReshapeFunc(ChangeSize);
    //特殊键位函数(上下左右)
    glutSpecialFunc(SpecialKeys);
    //渲染界面显示函数
    glutDisplayFunc(RenderScene);
    //判断一下是否能初始化glew库,确保项目能正常使用OpenGL 框架
    GLenum err = glewInit();
    if (GLEW_OK  != err) {
        fprintf(stderr, "GLEW Error:%s\n",glewGetErrorString(err));
        return 1;
    }
    //初始化画布
    SetupRC();
    //启动runloop
    glutMainLoop();
    return 0;
}



绘制的甜甜图效果

这么看起来似乎还是很完美的,但是当我们按键盘上的“上下左右”键进行旋转时,却出现了如下图的效果

这是怎么回事呢?明明RenderScene函数里面设置了红色呀,为什么会出现黑色呢?

//设置绘图颜色
    GLfloat vRed[] = {1.0,0.0,0.0,1.0};

正背面剔除,也称为“隐藏面消除”

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

如何理解消除隐藏面?

举个粟子

当太阳照射地球的时候,由于地球是圆的,所以太阳只能照射到地球的一面,另一面就照不到,也就有了白天和黑夜之分。相对于太阳来说,太阳只能看到地球的白天那一面,黑夜那一面看不到。

再举个粟子

桌面上的易拉罐,我们在看易拉罐时,只能看到一个面,而其他面我们看不到,要想看到只能将易拉罐旋转,或者人走到对应的位置去看。

甜甜圈、地球和易拉罐它们都有一个共同的特征,就是3D物体。

手机和电脑屏幕是平面的,当显示3D物体时,当然只能显示3D图形的某一面,那计算机上显示3D图形的某一面时,是否需要将3D物体的所以面都渲染到屏幕上呢?很显然是没有必要的。不管3D图形有多少个面,我们只需要在屏幕上绘制我们看得到的那个正面就行了。当3D物体发生了旋转,或者观察者发生了移动时,我们会重新得到新的正面,然后重新绘制新的正面。这种做法使得计算机也不用再去处理背面的图形数据,大大减少了计算量,提升了性能。

如何消除隐藏面?

上面粟子中提到的甜甜圈、地球、易拉罐等3D物体都我们看得到的面和看不到的面,我们把看得到的面称为正面,看不到的面称为背面。

在计算机屏幕上渲染这种3D物体时,我们只需要告诉计算机,把正面留着,背面丢掉就可以啦!

那么如何判断是正面还是背面呢?

我们知道,OpenGL连接顶点的方式只有三个形式:点、线和三角形。 我们在渲染复杂图形时,为了起来复用顶点和节约性能的目点,我们会采用三角形的方式。但是绘制三角形的方式也顺时针绘制和逆时针绘制。

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

以上面逆时针图形为例:

当我们从屏幕上看到它是逆时针的,但如果我们从屏幕的后面再来看这个三角形时,它又变成了背面了。

所以,一个3D物体我们看得到的面,是用逆时针(正面)的方式绘制的;我们看不到的面,是用顺时针(背面)的方式绘制的。所以我们只需面跟计机算约定好,看得到的是正面,看不到的是背面,在绘制时,把背面剔除。

这就是正背面剔除!

OpenGL中如何实现正背面剔除

老规矩,上代码

//标记:背面剔除
int iCull = 0;
  • 第一步:在main函数中增加右击菜单,并实现点击事件的方法
//添加右击菜单栏
    glutCreateMenu(ProcessMenu);
    glutAddMenuEntry("正背面剔除", 1);
    glutAttachMenu(GLUT_RIGHT_BUTTON);
  • 第二步:实现点击事件函数
void ProcessMenu(int value){
    switch (value) {
        case 0:
        {
           
        }
            break;
        case 1:
        {
            iCull = !iCull;
        }
            break;
   
    }
    //触发重新渲染
    glutPostRedisplay();
}
  • 第三步:修改RenderScene函数,加上如下代码
if (iCull) {
//打开正背面剔除
        glEnable(GL_CULL_FACE);
        
    }else{
    //关闭正背面剔除
        glDisable(GL_CULL_FACE);
    }

其实要开启正面剔除,只需要实现glEnable(GL_CULL_FACE);就够了,不过用完记得关闭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

看一下效果

OK,这样就看不到背面的黑色了。但是这样就可以了吗?

当我们把甜甜圈旋转到这个位置时,发现它被“咬了一口”,这是怎么回事呢???

深度测试

什么是深度?

深度就是在OpenGL坐标中,像素点的Z坐标距离观察者的距离。

什么是深度缓冲区?

深度缓存区,就是⼀块内存区域,专⻔存储着每个像素点(绘制在屏幕上的)深度值.

为什么需要深度缓冲区?

在不使用深度测试的时候,如果我们先绘制⼀个距离比较近的物体,再绘制距离较远的物体,则距离远的位图因为后绘制,会把距离近的物体覆盖掉. 有了深度缓冲区后,绘制物体的顺序就不那么重要了。

体物近靠越就者察观 .小越或大越值数 Z说的单简能不 .以所 ,置位意任的系标坐在放以可者察观当

  • 如果观察者在Z轴的正方向,Z值越大则越靠近观察者;
  • 如果观察者在Z轴的负方向,Z值越小则越靠近观察者。

解释了那么多,到底是什么原因导致了甜甜圈被“咬了一口”

我们知道,甜甜圈是一个3D图形,绘制它的每一个像素点都有自己的深度值(Z值),绘制过程中,会把所有的正面都绘制一次,如上图,当先绘制A面的红色部分后,再绘制B面的黑色阴影部分时,缓冲区里面存的就是B面的颜色数据,所以渲染到屏幕上显示的也是B面的颜色,从而出现了甜甜圈被“咬一口”的情况。

为了解决这种由于绘制先后关系而导致的显示异常问题,我们启用了深度测试。

深度测试的定义

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

为了解决甜甜圈被“咬一口”的问题

开启深度测试时当绘制一个像素点时,要将该像素点的深度值与深度缓存区对应像素点的深度值进行比较,如果观察者在Z轴的正方向,谁大存谁;如果观察者在Z轴的负向,谁小存谁,这样就保证了绘制的像素点一定是离观察者最近的像素点,从而解决了甜甜圈被“咬一口”的问题。

如何启用深度测试?

上代码 右击菜单栏增加“深度测试”

//背面剔除、
int iDepth = 0;

右击菜单栏增加“深度测试”

glutAddMenuEntry("深度测试", 0);

ProcessMenu 函数增加case 0

void ProcessMenu(int value){
    switch (value) {
        case 0:
        {
            iDepth = !iDepth;
        }
            break;
        case 1:
        {
            iCull = !iCull;
        }
            break;
   
    }
    glutPostRedisplay();
}

RenderScene 开启深度测试

//深度测试
    if (iDepth) {
        glEnable(GL_DEPTH_TEST);
    }else{
        glDisable(GL_DEPTH_TEST);
    }

运行查看效果

我们发现,经过深度测试,我们不仅解决了甜甜圈被“咬一口”的问题。而且,也解决了正背面剔除的问题。这也是当然的,因为正面的深度值肯定离观察者更近而被渲染到了屏幕上,背面的深度值离观察者更远而没有被渲染到屏幕上。

多边形偏移

Z-Fighting现象出现的原因?

通过上面的学习我们知道,深度测试的原理是比较两个像素点的深度值(Z值)来判断在深度缓冲区中到底保存哪个像素去渲染到屏幕上的,那么当两个图层离得非常近,也就是两个像素点的深度值(Z值)非常接近时,那会如何呢?

比如:A、B两个像素点的深度值分别是1.00000000001和1.00000000000时,由于计算机精度的问题,计算机会判断这两个深度值是一样大的,那这个时候就会出现如下现像了:

Az部分和Bz部分的深度值非常接近,计算机判断它们的深度值是一样大小的,这时候就会出现A、B两种显示效果。这就是Z-Fighting现象.

如何解决Z-Fighting现象?--多边形偏移

既然造成Z-Fighting现象的原因是两个图层离得太近了,那我们就让他们离得远一些嘛,这样不就可以解决问题了吗?

上代码 开启多边形偏移,让两个深度值特别相近的像素点保持距离

glEnable(GL_POLYGON_OFFSET_FILL);

关闭多边形偏移

glDisable(GL_POLYGON_OFFSET_FILL);

如何预防Z-Fighting闪烁问题

  • 不要将两个物体靠得太近,避免渲染时叠在一起。这种方式要求对场景中物体插入一个少是的偏移,那么就可能避免Z-Fighting现象。
  • 尽可能将近裁剪面设置得离观察者远一些,在近裁剪面附近,深度的精度是很高的,因此尽可能让近裁剪面远一些的话,会使整个裁剪范围内的精确度高一些。但是这种方式会使离观察者较近的物体裁剪掉,因此需要调试好裁剪面参数.
  • 使用更高位的深度缓冲区,通常使用的深度缓冲区是24位的,现在有一些硬件使用32位或64位的缓冲区,使精确度得到提高。

End