日期:2025年6月19日标签:Computer Graphics

opengl part 3: glsl、uniform 和 texture #

现代 opengl 要求我们必须对顶点着色器和片段着色器进行编程,而写着色器代码使用的语言是一种类 C 的着色器语言(GLSL, OpenGL Shading Language)。

GLSL #

着色器的开头总是要声明版本,接着是输入和输出变量、uniform 和 main 函数,每个着色器入口点都是 main 函数。下面是一个典型的 glsl 代码模板。

#version version_number
in type in_variable_name;
in type in_variable_name;

out type out_variable_name;

uniform type uniform_name;

void main()
{
  // 处理输入并进行一些图形操作
  ...
  // 输出处理过的结果到输出变量
  out_variable_name = weird_stuff_we_processed;
}

输入和输出 #

GLSL 定义了 inout 关键字专门来实现这个目的。每个着色器使用这两个关键字设定输入和输出,只要一个输出变量与下一个着色器阶段的输入匹配,它就会传递下去。顶点着色器和片段着色器有点不同。

顶点着色器由于是渲染管线中的第一个着色器,所以顶点着色器的输入变量来自于 cpu,也就是在前一篇文章中提到的顶点属性(vertex attribute)。我们在 vbo 上定义数据,配置 vao 告诉 gpu 如何解析顶点数据。另外顶点着色器必须输出 gl_Position 变量,gl_Position 是 GLSL 内置变量,名称固定。

片段着色器需要一个 vec4 颜色输出变量,因为片段着色器需要生成一个最终输出的颜色。如果你在片段着色器没有定义输出颜色,OpenGL会把你的物体渲染为黑色(或白色)。这个输出颜色变量名称可以自己指定任意合法的名称,例如定义 out vec4 FragColor;

所以如果我们打算从一个着色器向另一个着色器发送数据,我们必须在发送方着色器中声明一个输出,在接收方着色器中声明一个类似的输入。当类型和名字都一样的时候,OpenGL就会把两个变量链接到一起,它们之间就能发送数据了

uniform #

从 cpu 上传递数据到 gpu 上除了使用顶点属性(vertex attribute)外,还可以使用 uniform 变量。uniform 和顶点属性不同的是 uniform 是全局的(Global),全局意味着 uniform 变量必须在每个着色器程序对象中都是独一无二的,而且它可以被着色器程序的任意着色器在任意阶段访问。而顶点属性只能被顶点着色器访问。此外,无论你把 uniform 值设置成什么,uniform 会一直保存它们的数据,直到它们被重置或更新。

glsl 中通过 uniform 关键字声明 uniform。

#version 330 core
out vec4 FragColor;

uniform vec4 ourColor; // 在OpenGL程序代码中设定这个变量

void main()
{
    FragColor = ourColor;
}

uniform是全局变量,我们可以在任何着色器中定义它们,而无需通过顶点着色器作为中介。

在 OpenGl 程序中设置 Uniform,需要先找到着色器中 uniform 属性的索引/位置。得到 uniform 的索引/位置值后,就可以更新它的值了。比如:

float timeValue = glfwGetTime();
float greenValue = (sin(timeValue) / 2.0f) + 0.5f;
int vertexColorLocation = glGetUniformLocation(shaderProgram, "ourColor");   // 获取 ourColor 的位置
glUseProgram(shaderProgram); // 设置值之前必须先执行 glUseProgram,因为他是在当前激活的着色器程序中设置 uniform 的
glUniform4f(vertexColorLocation, 0.0f, greenValue, 0.0f, 1.0f); // 设置 ourColor 的值

纹理 Texture #

1. 纹理是什么 #

纹理可以理解为存储在 GPU 中的一块图像或数据。

最常见的用途是把一张二维图片贴到几何体表面:

顶点纹理坐标
      ↓
在纹理中找到对应位置
      ↓
读取纹素颜色
      ↓
片段着色器输出颜色

GLSL 中通过 sampler2D 访问二维纹理:

uniform sampler2D ourTexture;

void main()
{
    FragColor = texture(ourTexture, TexCoord);
}

其中:

  • ourTexture:纹理采样器;
  • TexCoord:纹理坐标;
  • texture():根据纹理坐标读取纹理颜色。

2. 纹理坐标 #

纹理坐标用于描述几何体上的点对应纹理图片的哪个位置

二维纹理坐标通常写成:

(s, t)

也经常写成:

(u, v)

标准范围一般为:

s ∈ [0, 1]
t ∈ [0, 1]

对应关系:

(0,1) ───────── (1,1)
  │                │
  │     texture    │
  │                │
(0,0) ───────── (1,0)

例如给矩形设置纹理坐标:

float vertices[] = {
    // 位置             // 纹理坐标
     0.5f,  0.5f, 0.0f, 1.0f, 1.0f,
     0.5f, -0.5f, 0.0f, 1.0f, 0.0f,
    -0.5f, -0.5f, 0.0f, 0.0f, 0.0f,
    -0.5f,  0.5f, 0.0f, 0.0f, 1.0f
};

顶点着色器将纹理坐标传递给片段着色器:

#version 330 core

layout(location = 0) in vec3 aPos;
layout(location = 1) in vec2 aTexCoord;

out vec2 TexCoord;

void main()
{
    gl_Position = vec4(aPos, 1.0);
    TexCoord = aTexCoord;
}

片段着色器接收插值后的纹理坐标:

in vec2 TexCoord;

uniform sampler2D ourTexture;

void main()
{
    FragColor = texture(ourTexture, TexCoord);
}

三角形内部每个片段的纹理坐标由顶点纹理坐标自动插值得到。


3. 纹理环绕 #

当纹理坐标超出 [0,1] 时,OpenGL 需要决定如何读取纹理,这就是纹理环绕方式。

例如:

TexCoord = (1.5, 0.5)

横坐标已经超出了纹理范围。

常见环绕方式如下。

texture wrap

3.1 GL_REPEAT #

重复纹理。

glTexParameteri(
    GL_TEXTURE_2D,
    GL_TEXTURE_WRAP_S,
    GL_REPEAT
);

glTexParameteri(
    GL_TEXTURE_2D,
    GL_TEXTURE_WRAP_T,
    GL_REPEAT
);

3.2 GL_MIRRORED_REPEAT #

重复纹理,但相邻纹理镜像翻转。

GL_CLAMP_TO_EDGE #

超出范围后,使用纹理边缘的颜色。

常用于避免纹理边缘出现接缝。

GL_CLAMP_TO_BORDER #

超出纹理范围后,使用指定的边框颜色:

float borderColor[] = {
    1.0f, 1.0f, 1.0f, 1.0f
};

glTexParameterfv(
    GL_TEXTURE_2D,
    GL_TEXTURE_BORDER_COLOR,
    borderColor
);

S 和 T 方向 #

二维纹理有两个方向:

S:水平方向
T:垂直方向

因此一般需要分别设置:

glTexParameteri(
    GL_TEXTURE_2D,
    GL_TEXTURE_WRAP_S,
    GL_REPEAT
);

glTexParameteri(
    GL_TEXTURE_2D,
    GL_TEXTURE_WRAP_T,
    GL_REPEAT
);

4. 纹理过滤 #

纹理过滤用于解决:

屏幕像素与纹理像素不能一一对应时,应该如何计算颜色。

纹理中的一个像素称为:

Texel,纹素

英文全称为 Texture Element。

OpenGL 中主要有两种基础过滤方式。

GL_NEAREST #

选择距离纹理坐标最近的一个纹素:

glTexParameteri(
    GL_TEXTURE_2D,
    GL_TEXTURE_MAG_FILTER,
    GL_NEAREST
);

特点:

  • 速度快;
  • 边缘清晰;
  • 放大后有明显像素块;
  • 适合像素画。

GL_LINEAR #

读取附近多个纹素并进行线性插值:

glTexParameteri(
    GL_TEXTURE_2D,
    GL_TEXTURE_MAG_FILTER,
    GL_LINEAR
);

特点:

  • 过渡平滑;
  • 放大时比较模糊;
  • 普通图片通常使用这种方式。

放大过滤与缩小过滤 #

OpenGL 将过滤分为两种情况。

放大过滤:纹理比较小,但显示在较大的屏幕区域:

64 × 64 纹理
    ↓
显示为 500 × 500

通过下面的参数设置:

GL_TEXTURE_MAG_FILTER

可选值主要是:

GL_NEAREST
GL_LINEAR

缩小过滤:纹理比较大,但显示在较小的屏幕区域:

1024 × 1024 纹理
      ↓
显示为 20 × 20

通过下面的参数设置:

GL_TEXTURE_MIN_FILTER

缩小时可以使用普通过滤,也可以配合 Mipmap。


5. Mipmap #

Mipmap 是同一张纹理的一系列不同分辨率版本。

例如原始纹理为:

Level 0:1024 × 1024
Level 1: 512 × 512
Level 2: 256 × 256
Level 3: 128 × 128
...
最终:       1 × 1

当物体靠近摄像机时,使用较高分辨率纹理;当物体远离摄像机时,使用较低分辨率纹理。

作用:

  • 减少远处纹理闪烁;
  • 减少锯齿和摩尔纹;
  • 提高纹理采样效率;
  • 避免远处物体仍然从超高分辨率纹理中采样。

上传原始纹理后,可以自动生成 Mipmap:

glGenerateMipmap(GL_TEXTURE_2D);

常见的 Mipmap 缩小过滤方式:

GL_NEAREST_MIPMAP_NEAREST
GL_LINEAR_MIPMAP_NEAREST
GL_NEAREST_MIPMAP_LINEAR
GL_LINEAR_MIPMAP_LINEAR

最常用的是:

glTexParameteri(
    GL_TEXTURE_2D,
    GL_TEXTURE_MIN_FILTER,
    GL_LINEAR_MIPMAP_LINEAR
);

它会:

  1. 在相邻纹素之间进行线性插值;
  2. 在相邻两个 Mipmap 层级之间进行线性插值。

这种方式通常称为三线性过滤。

Mipmap 主要用于纹理缩小,因此不能用于:

GL_TEXTURE_MAG_FILTER

下面这种设置是无效的:

glTexParameteri(
    GL_TEXTURE_2D,
    GL_TEXTURE_MAG_FILTER,
    GL_LINEAR_MIPMAP_LINEAR
);

放大过滤只能使用:

GL_NEAREST
GL_LINEAR

6. 纹理对象 #

纹理对象用于保存:

  • 纹理图片数据;
  • 宽度和高度;
  • 像素格式;
  • 环绕方式;
  • 过滤方式;
  • Mipmap 数据。

创建纹理对象:

unsigned int texture = 0;
glGenTextures(1, &texture);

绑定纹理对象:

glBindTexture(GL_TEXTURE_2D, texture);

绑定以后,后续的纹理设置都会作用于当前绑定的纹理对象:

glTexParameteri(...);
glTexImage2D(...);
glGenerateMipmap(...);

7. 纹理单元 #

一个着色器可能需要同时读取多张纹理,例如:

  • 漫反射纹理;
  • 法线纹理;
  • 粗糙度纹理。

OpenGL 通过 纹理单元(Texture Unit) 让着色器可以同时访问多个纹理对象。

可以把纹理单元理解为 GPU 提供的一组“纹理插槽”:

纹理单元 0
纹理单元 1
纹理单元 2
...

7.1 GLSL 中使用 sampler2D #

假设片段着色器中声明了两个采样器:

uniform sampler2D diffuseMap;
uniform sampler2D normalMap;

这里:

diffuseMap
normalMap

只是 GLSL 中两个 uniform 变量的名称

它们的类型都是:

sampler2D

也就是二维纹理采样器。

它们本身并不保存纹理图片,也不是 OpenGL 的纹理对象 ID

采样器保存的是:

应该从哪个纹理单元读取纹理。

例如我们希望:

GLSL sampler          纹理单元

diffuseMap    ─────→  纹理单元 0

normalMap     ─────→  纹理单元 1

7.2 CPU 设置 sampler 指向哪个纹理单元 #

首先使用对应的 shader program:

glUseProgram(shaderProgram);

获取 GLSL 中:

uniform sampler2D diffuseMap;

这个 uniform 的位置:

GLint diffuseLocation =
    glGetUniformLocation(shaderProgram, "diffuseMap");

然后设置:

glUniform1i(diffuseLocation, 0);

这里的:

0

表示:

diffuseMap纹理单元 0 读取纹理。

同样:

GLint normalLocation =
    glGetUniformLocation(shaderProgram, "normalMap");

glUniform1i(normalLocation, 1);

表示:

normalMap纹理单元 1 读取纹理。

因此现在建立了:

diffuseMap → 纹理单元 0
normalMap  → 纹理单元 1

注意这里传入的:

0
1

纹理单元编号,不是纹理对象 ID。

7.3 给纹理单元绑定真正的纹理对象 #

假设 CPU 中已经创建好了两个纹理对象:

GLuint diffuseTexture;
GLuint normalTexture;

例如:

diffuseTexture = 5
normalTexture  = 12

其中 512 是 OpenGL 纹理对象 ID。

现在把 diffuseTexture 绑定到纹理单元 0:

glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, diffuseTexture);

含义是:

纹理单元 0
    ↓
绑定纹理对象 diffuseTexture

再把 normalTexture 绑定到纹理单元 1:

glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, normalTexture);

于是:

纹理单元 1
    ↓
绑定纹理对象 normalTexture

最终完整关系是:

GLSL sampler       纹理单元          OpenGL 纹理对象

diffuseMap  ───→  Unit 0  ───→  diffuseTexture
                                      ID = 5

normalMap   ───→  Unit 1  ───→  normalTexture
                                      ID = 12

这是理解纹理单元最重要的关系。

7.4 片段着色器如何读取纹理 #

GLSL:

uniform sampler2D diffuseMap;
uniform sampler2D normalMap;

in vec2 TexCoord;

out vec4 FragColor;

void main()
{
    vec4 diffuseColor =
        texture(diffuseMap, TexCoord);

    vec3 normal =
        texture(normalMap, TexCoord).rgb;

    FragColor = diffuseColor;
}

当执行:

texture(diffuseMap, TexCoord)

实际查找过程可以理解为:

diffuseMap
    ↓
里面保存的是 0
    ↓
找到纹理单元 0
    ↓
纹理单元 0 当前绑定 diffuseTexture
    ↓
从 diffuseTexture 中采样

而:

texture(normalMap, TexCoord)

则是:

normalMap
    ↓
里面保存的是 1
    ↓
找到纹理单元 1
    ↓
纹理单元 1 当前绑定 normalTexture
    ↓
从 normalTexture 中采样

8. 多纹理示例 #

片段着色器:

#version 330 core

out vec4 FragColor;

in vec2 TexCoord;

uniform sampler2D texture1;
uniform sampler2D texture2;

void main()
{
    vec4 color1 = texture(texture1, TexCoord);
    vec4 color2 = texture(texture2, TexCoord);

    FragColor = mix(color1, color2, 0.2);
}

CPU 设置采样器:

shader.use();

shader.setInt("texture1", 0);
shader.setInt("texture2", 1);

绘制前绑定纹理:

glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, firstTexture);

glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, secondTexture);

shader.use();
glBindVertexArray(VAO);
glDrawElements(
    GL_TRIANGLES,
    6,
    GL_UNSIGNED_INT,
    nullptr
);

对应关系:

texture1 → 纹理单元 0 → firstTexture
texture2 → 纹理单元 1 → secondTexture

9. 完整的纹理创建流程 #

下面以 stb_image 加载二维图片为例。

unsigned int texture = 0;
glGenTextures(1, &texture);

// 绑定纹理对象
glBindTexture(GL_TEXTURE_2D, texture);

// 设置纹理环绕
glTexParameteri(
    GL_TEXTURE_2D,
    GL_TEXTURE_WRAP_S,
    GL_REPEAT
);

glTexParameteri(
    GL_TEXTURE_2D,
    GL_TEXTURE_WRAP_T,
    GL_REPEAT
);

// 设置纹理过滤
glTexParameteri(
    GL_TEXTURE_2D,
    GL_TEXTURE_MIN_FILTER,
    GL_LINEAR_MIPMAP_LINEAR
);

glTexParameteri(
    GL_TEXTURE_2D,
    GL_TEXTURE_MAG_FILTER,
    GL_LINEAR
);

// 加载图片
int width = 0;
int height = 0;
int channels = 0;

stbi_set_flip_vertically_on_load(true);

unsigned char* data = stbi_load(
    "container.jpg",
    &width,
    &height,
    &channels,
    0
);

if (data != nullptr)
{
    GLenum format = GL_RGB;

    if (channels == 1)
    {
        format = GL_RED;
    }
    else if (channels == 3)
    {
        format = GL_RGB;
    }
    else if (channels == 4)
    {
        format = GL_RGBA;
    }

    // 上传图片数据到纹理对象
    glTexImage2D(
        GL_TEXTURE_2D,
        0,
        format,
        width,
        height,
        0,
        format,
        GL_UNSIGNED_BYTE,
        data
    );

    // 生成 Mipmap
    glGenerateMipmap(GL_TEXTURE_2D);
}
else
{
    std::cerr << "Failed to load texture\n";
}

stbi_image_free(data);

10. 绘制时使用纹理 #

初始化阶段设置 sampler:

shader.use();
shader.setInt("ourTexture", 0);

渲染循环中绑定纹理:

while (!glfwWindowShouldClose(window))
{
    glClear(GL_COLOR_BUFFER_BIT);

    shader.use();

    glActiveTexture(GL_TEXTURE0);
    glBindTexture(GL_TEXTURE_2D, texture);

    glBindVertexArray(VAO);

    glDrawElements(
        GL_TRIANGLES,
        6,
        GL_UNSIGNED_INT,
        nullptr
    );

    glfwSwapBuffers(window);
    glfwPollEvents();
}

11. 整体流程总结 #

1. glGenTextures
   创建纹理对象

2. glBindTexture
   绑定纹理对象

3. glTexParameteri
   设置环绕方式和过滤方式

4. 读取图片
   获取像素数据

5. glTexImage2D
   将像素数据上传到 GPU

6. glGenerateMipmap
   生成多级渐远纹理

7. glActiveTexture
   选择纹理单元

8. glBindTexture
   将纹理对象绑定到纹理单元

9. glUniform1i
   让 sampler 指向纹理单元

10. texture()
    在片段着色器中采样纹理

最核心的对应关系可以记成:

纹理坐标:决定从纹理的哪里读取

纹理环绕:决定坐标超出 [0,1] 后如何读取

纹理过滤:决定多个纹素如何计算成最终颜色

Mipmap:为远处物体选择合适分辨率的纹理

纹理对象:保存实际纹理数据和参数

纹理单元:连接 sampler 和纹理对象

sampler2D:着色器中用于访问二维纹理的变量

(完)

目录