计算机图形:可编程着色器

发布时间 2023-12-15 22:32:22作者: 明明1109

OpenGL渲染流水线

图形API提供对硬件操作的标准接口,对程序员提出的各种绘制图元或属性的请求都采用固定的方式来处理. 这种内部事先方式,称为固定功能渲染流水线(fixed-function rendering pipeline).

为了能突破固定功能流水线,充分应用硬件新功能,开发者在渲染流水线中提供“钩函数”(hooks). 提供hooks,可使用可编程着色器修改流水线中某些特定步骤的行为. 这些效果是原有的固定流水线无法实现的.

固定功能流水线

OpenGL初版内部流程被组织为双通道的渲染流水线,对输入数据执行特定的操作. 这种工作方式,称为固定功能的OpenGL流水线.

img

如上图,不同类型信息被流水线上半部分或下半部分处理. 其中,几何图元由上半部处理处理,称为几何流水线(geometric pipeline),像素由下半部处理,称为像素流水线(pixel pipeline). 两种信息都能存储在显示列表(display list)中. 当一个显示列表被显示时,包含的信息就会送到相应的流水线.

几何流水线中,图元被描述为一些顶点的集合,同时还包含属性信息,如材质、纹理坐标、法向量等. 顶点操作、图元装配对这些信息进行大量处理. 顶点经过建模和观察变换,自动生成纹理坐标(如果允许),裁剪和阴影计算后进行光照计算,图元光栅化确定每个图元转换为哪些位置的像素.

像素流水线中,像素数据从主存、像素缓存、纹理内存或帧缓存中获取,对数据处理后的结果写入纹理缓存或重新光栅化.

完全光栅化的几何和像素数据被合并到一起,构成一组“片元”(fragment). 片元的数据结构记录每个像素,包含需要随时更新、记入帧缓存的信息. 片元创建后,交由片元处理阶段,完成最终的向显示格式的转换. 如果一个片元完成了纹理映射,它就会包含通过纹理内存中的信息生成的纹素,在需要的时候可以进行雾气和反走样处理. 最后完成深度缓存测试,片元的最终处理结果被写入帧缓存.

可编程功能流水线

固定功能流水线不能充分发挥现代图形硬件的能力,对OpenGL流水线修改如下:

img

可以看到,固定的顶点操作、片元操作阶段被用户编程控制的处理器阶段替换了. 好处是由OpenGL编程决定对这些点进行什么样的处理,能有效发挥硬件能力.

APP通过着色器(shader)控制用户可编程阶段的操作.着色器,指一些较为简短的程序段,被加载到OpenGL程序中,最终加入到OpenGL流水线的处理单元中,替换流水线原来的固定功能.

思考:顶点处理器/片元处理器与着色器是什么关系?
处理器阶段可以回调用户定义的着色器程序,从而替换原有的固定功能.

顶点着色器

顶点着色器(vertex shader):一个着色程序,用于替代原流水线中顶点操作阶段.

主要负责:
1)对每个送入流水线的顶点进行处理,负责生成所有后续阶段需要的信息
2)输出每个顶点经过建模、观察投影变换后在裁剪空间(clip space)中的坐标

裁剪空间是流水线后续一直使用的坐标系空间,可以生成或转换纹理坐标.

3)为顶点赋予颜色值,可以生成或转换纹理坐标,在顶点上使用光照和表面法向量进行计算

4)通过特定内置全局变量获取所需信息

片元着色器

片元着色器(fragment shader)主要负责:流水线中某个阶段的数据基础上进行处理,将处理后的数据传递给后续阶段,即对光栅化后的顶点和像素(即片元)进行操作.

片元着色器处理对象是片元,对流水线中每个片元执行一次. 着色器可能执行多次(取决于图元如何光栅化). 着色器需要根据对象的颜色来分配片元颜色值,能进行纹理应用和凹凸映射.

几何着色器

几何着色器(geometry shader):对流水线中图元装配阶段输出的结果进一步处理.

几何着色器处理对象是图元,对每个图元执行一次,可获取该图元有关的所有顶点信息.

与顶点着色器、片元着色器不同:
几何着色器不只是修改输入数据后输出,还可能创建新的图元交给后续流水线.

曲面细分着色器

曲面细分处理由一对着色器控制:曲面细分控制着色器(tessellation control shader),曲面细分评价着色器(tessellation evaluation shader)—— 专门处理“块”(patch)的图元.
块指顶点、每个顶点的属性、块属性的总称. 曲面细分着色器拿到一个输入块后,将其分为一组点、线、三角形的集合,然后输出块信息并传递给流水线的后续阶段.

OpenGL着色语言(GLSL)

OpenGL着色语言,OpenGL Shading Language(简称GLSL),类似于C、与硬件无关,用于支持着色器开发.

提供多种类型,如向量、颜色、矩阵等;提供内置运算符简化操作.

与C/C++重要不同:GLSL在类型检查方面非常严格,不支持指针和数据类型的隐式转换.

查询当前使用的OpenGL和GLSL版本:

// 打印OpenGL版本信息
printf("OpengGL version: %s\n", (char *)glGetString(GL_VERSION));
// 打印GLSL版本信息
printf("GLSL version: %s\n", (char *)glGetString(GL_SHADING_LANGUAGE_VERSION));

着色器结构

GLSL程序通常包含顶点着色器、片元着色器,每个着色器包含一个main函数. 着色器也包含一些支撑函数、全局变量,便于实现顶点着色器和片元着色器间的接口.

顶点着色器会对流水线上的每个顶点执行一次,至少完成将顶点坐标转换到裁剪空间的工作,方法:将顶点坐标(gl_Vertex)和建模矩阵(gl_ModelViewMatrix)相乘,再和投影变换矩阵(gl_ProjectionMatrix)相乘.

这些矩阵(建模矩阵、投影矩阵)作为内置的全局变量(前缀gl_),被顶点着色器获取并使用.转换后的结果存放到全局变量gl_Position中.

  1. 顶点着色器示例:顶点的建模变换、投影变换

有3种等价方法:
1)顶点坐标乘以内置的建模矩阵、投影变换矩阵.

void main()
{
    gl_Position=gl_ProjectioniMatrix * (gl_ModelViewMatrix * gl_Vertex);
}

2)用全局变量gl_ModelViewProjectionMatrix,包含投影变换矩阵、建模矩阵的乘积,也能实现同样的变换. 好处是能缩减为一次矩阵乘法.

3)使用内置函数.

gl_Position=ftransform();
  1. 片元着色器示例:为片元计算颜色
void main()
{
    gl_FragColor=gl_Color;
}

内置变量gl_Color表示当前顶点颜色,gl_FragColor表示当前片元颜色.

OpenGL中使用着色器

着色器是OpenGL运行时编译. 使用顶点和片元着色器步骤:
1)创建2个着色器对象;
2)将源代码关联到每个着色器对象;
3)编译着色器;
4)创建一个程序对象;
5)将着色器对象关联到程序对象;
6)链接程序.

着色器源码须是以NULL结尾的C标准字符串,通常放到文本文件中.

示例:读取文本文件内容,并存入一个动态分配的字符缓冲区中.
该程序可用于辅助读取着色器程序文本文件.

/// Read the text file as C-style string. User should free the returned memory when no need to use the string.
GLbyte* readTextFile(const char* name)
{
    FILE* fp;
    GLbyte* content = NULL;
    int count = 0;

    /* verify that we were actuall given a name */
    if (!name) return NULL;

    /* attemp to open the file */
    fp = fopen(name, "rt");
    if (!fp) return NULL;

    /* determine the length of the file */
    fseek(fp, 0, SEEK_END);
    count = ftell(fp);
    rewind(fp);

    /* allocate a buffer and read the file into it */
    if (count > 0) {
        content = (GLbyte*)malloc(sizeof(GLbyte) * (count + 1));
        if (content) {
            fread(content, sizeof(GLbyte), count, fp);
            content[count] = '\0';
        }
    }

    fclose(fp);
    return content;
}
  • 创建着色器对象
GLuint vertShader, flagShader;

vertShader = glCreateShader(GL_VERTEX_SHADER);
flagShader = glCreateShader(GL_FRAGMENT_SHADER);
  • 将源代码关联到着色器对象

(1)从文本文件获取着色器程序的源字符串

    GLchar* vertSource, *fragSource;

    vertSource = readTextFile("simpleShader.vert");
    if (!vertSource) {
        fputs("Failed to read vertex shader\n", stderr);
        exit(EXIT_FAILURE);
    }

    fragSource = readTextFile("simpleShader.frag");
    if (!fragSource) {
        fputs("Failed to read fragment shader\n", stderr);
        exit(EXIT_FAILURE);
    }

(2)关联到着色器对象

    glShaderSource(vertShader, 1, (const GLchar**)&vertSource, NULL);
    glShaderSource(fragShader, 1, (const GLchar**)&fragSource, NULL);

    free(vertSource);
    free(fragSource);

glShaderSource允许将多个着色器字符串关联到同一个着色器对象.
第一个参数,指定了所用的着色器对象;第二个,指定了源字符串的个数;第三个,指向这些字符串的指针数组;第四个,通知glShaderSource字符串是以NULL结尾的.

  • 编译着色器

运行时编译着色器程序

glCompileShader(vertShader);
glCompileShader(fragShader);

如何判断编译是否成功?
可以调用glGetShaderiv查询.

    // check if success to compile shaders
    GLint status;

    glGetShaderiv(vertShader, GL_COMPILE_STATUS, &status);
    if (status != GL_TRUE) {
        fputs("Error in vertex shader compilation\n", stderr);
    exit(EXIT_FAILURE);
    }

    glGetShaderiv(fragShader, GL_COMPILE_STATUS, & status);
    if (status != GL_TRUE) {
        fputs("Error in fragment shader compilation\n", stderr);
    exit(EXIT_FAILURE);
    }
  • 创建程序对象,并将着色器与之关联,最后链接整个程序
    // link shader to program
    GLuint program;
    program = glCreateProgram();

    glAttachShader(program, vertShader);
    glAttachShader(program, fragShader);

    // link program
    glLinkProgram(program);

如何判断链接是否成功?
可调用glGetProgramiv查询链接状态.

    // check if success to link
    glGetProgramiv(vertShader, GL_LINK_STATUS, &status);
    if (status != GL_TRUE) {
        fputs("Error when linking shader program\n", stderr);
        exit(EXIT_FAILURE);
    }

如果想知道哪里出错,怎么办?
可查询着色器或程序信息日志,了解出错详细信息. 需要查询日志长度,将日志读入字符缓冲区再输出.

下面例子用动态缓冲区实现:

    GLint length;
    GLsizei num;
    char* log;

    glGetShaderiv(vertShader, GL_INFO_LOG_LENGTH, &length);
    if (length > 0) {
        log = (char*)malloc(sizeof(char) * length);
        glGetShaderInfoLog(vertShader, length, &num, log);
        fprintf(stderr, "%s\n", log);
        free(log);
    }

    glGetProgramiv(program, GL_INFO_LOG_LENGTH, &length);
    if (length > 0) {
        log = (char*)malloc(sizeof(char) * length);
        glGetProgramInfoLog(program, length, &num, log);
        fprintf(stderr, "%s\n", log);
        free(log);
    }

日志查询函数glGetShaderInfoLog, glGetProgramInfoLog 第一个参数,指定要查询的日志所属的对象;第四个,指定缓冲区地址;第二个,指定缓冲区的尺寸;第三个,函数会写入缓冲区的实际字节数(不包括末尾的NULL).

  • 使用不同着色器

激活着色器程序,在绘制对象之前调用.

glUseProgram(program);

清空着色器程序,绘制对象完毕后调用.

glUseProgram(0);
  • 删除着色器或程序对象
// free shader not include program
glDeleteShader(vertShader);
// free program not include shader
glDelteProgram(program);

删除完成后,对象占用的内存空间会被释放,对象句柄被标记未“未使用”. 删除一个程序对象会解除它和着色器对象间的关联关系,但不会删除着色器对象,着色器对象仍可以正常使用.
可以调用glDetachShader显示解除着色器对象和程序对象的关联.

// unbind shader
glDetachShader(program, vertShader);
glDetachShader(program, fragShader);

基本数据类型

GLSL提供的数据类型比C多,分为标量、矢量、矩阵和取样器,都能用结构或数组封装.

标量类型(4种):整数(int),无符号整数(uint),布尔类型(bool),浮点数(float). 除位运算未,大部分C运算符都能在GLSL中使用.

变量声明与C/C++类似,可在着色器源码中任何需要的地方.

矢量

  • 矢量构成

4种类型的标量都能构成矢量,一个矢量可以有2、3、4个分量:vec2, vec3, vec4表示浮点类型的矢量,后缀2/3/4代表矢量的维数.

如果要定义其他类型标量构成的矢量,可以在(vec)前面加一个前缀字母,如ivec2,uvec2,bvec2 分别代表构成的标量类型未int,uint,bool.

  • 矢量初始化

类似C++语法. 下面方式将一个vec4变量的4个分量分别赋值1.0,2.0,3.0,4.0

vec4 a = vec4(1.0, 2.0, 3.0, 4.0);
  • 矢量操作

访问矢量分量的几种方式:
1)使用和数组类似的下标方式,第一个分量下标0,第二个1,...
2)使用结构体乘以的引用方式,如名为position的vec4有4个分量,可以访问position.x/.y/.z/.w,用r/g/b/a访问颜色矢量,用s/t/p/q访问纹理坐标矢量.
3)使用swizzling技术,访问矢量分量的集合,能同时访问多个分量.

vec4 v;

v.xyzw // a vec4 identical to v
v.xyz  // a vec3 containing the first three elements of v
v.rgb  // a vec3 containing the first three elements of v
v.y    // a float containing the second element
v.sp   // a vec2 containing the first and third elements

矩阵

方阵(n x n个元素)类型变量用mat2, mat3, mat3声明. 非方阵用matmxn声明,m为列数n为行数.

元素访问用数组下标,可以单独的下标访问整列. 矩阵采用列优先存储,第一个下标代表列号,第二个下标代表行号. 例如,定义了变量mat4 m,则m[2]是vec4类型,代表第三列的s所有元素,m[1][3]是一个浮点数,代表第4行、第2列的元素.

矩阵初始化用C++构造行数方式,列优先初始化:

mat2 m = mat2(1.0, 2.0, 3.0, 4.0);

将创建矩阵:

\[m=\begin{bmatrix} 1.0 & 3.0\\ 2.0 & 4.0 \end{bmatrix} \]

tips: 算术运算已被重载允许矩阵操作.

结构、数组

数组元素支持任何类型,包括矢量、矩阵、结构、标量.

结构成员支持着色器编译器能识别的任意类型,包括其他结构、数组.

struct lightsource {
    vec3 color;
    vec3 position;
};

lightsource desklamp;
lightsource spotlights[4];

GLSL类型检查比C/C++更严格,隐式类型转换仅允许整数到无符号整数、整数到浮点数的转换;其他类型转换必须显示. 转换用C++构造函数的语法.

控制结构

支持大部分C支持的控制结构.

循环结构:for, while, do-while. 循环内定义(局部)变量,break、continue.

分支结构:支持if-then, if-then-else, switch,不支持goto和标签. if语句中不允许定义变量. 条件表达式必须是布尔类型,不允许数值类型->布尔类型的隐式转换.

discard语句:片元着色器中有个特殊语句discard,可防止片元着色器在不必要的时候对帧缓存做修改. discard语句执行后,正在处理的片元就会被标记位“丢弃的”,着色器的执行不会对帧缓存产生影响.

GLSL 函数

函数定义、调用类似于C++,但也有不同.

  • 返回值:
    函数定义要显式给出返回值类型,可以是任意类型(包括结构、数组),void也合法.

  • 递归
    禁止直接或间接递归.

  • 重载

支持函数重载,通过函数参数类型、个数区分.

  • 参数限定方式:in,out,inout类型

函数参数限定为in,out,inout类型:
in表示实参被复制到形参,对于函数是输入参数;
out表示调用时实参被忽略,返回时值将传回给形参,对于函数是输出参数.
inout同时支持in和out.

  • 数组作为参数

数组不能用引用方式传递,必须复制到形参中.

与OpenGL通信

着色器通过同名全局变量与OpenGL程序通信. 全局变量的限定与函数参数类似:in, out, inout.

输出端将变量限定位out/inout,输入端将同名变量限定位in/inout.

OpenGL程序使用一组uniform全局变量负责将所有数据传递给着色器,通常包含不会经常修改的数据. 着色器可读取,但不能修改.

源数据:限定in类型的变量,代表数据从流水线的前一阶段传递到着色器中.
顶点着色器源数据一般来自OpenGL程序,变量类型仅限于数值类型的标量、向量或矩阵(非布尔类型).
片元着色器源数据可能来自OpenGL程序,也可能来自顶点着色器. 如果来自后者,则变量必须和顶点着色器中的一个out类型变量匹配.

tips: 老版本GLSL(1.30以前),不存在in/out限定,而用attribute和varying限定,目前GLSL 4.10.6仍支持,以后可能废弃.

定位全局变量

OpenGL程序如何修改着色器中的全局变量?
调用glGetUniformLocation定位uniform变量,然后再写入值.

GLint location;

location = glGetUniformLocation(program, "variableName");

其中,program是一个程序对象的句柄,由前面的glCreateProgram创建;"variableName"是想访问的uniform全局变量名.

查询指定位置变量值:

GLint i;
GLfloat f;

glGetUniformiv(program, location, &i);
glGetUniformfv(program, location, &f);

向指定位置值写入新值:

GLfloat v1, v2, v3, v4;

glUniform1f(location, v1);
glUniform2f(location, v1, v2);
glUniform3f(location, v1, v2, v3);

也可以将数组值写入指定位置:

GLfloat va[4];

glUniform1fv(location, 1, va);
glUniform1fv(location, 2, va);
glUniform1fv(location, 3, va);
glUniform1fv(location, 4, va);

glUniform*i, glUniform*iv系列函数可用来修改uniform整数变量值.

glUniform没有把程序对象句柄program作为参数,因此只能访问当前活动的程序对象(即最近一次调用glUseProgram激活的程序对象)的着色器变量.