OpenGL [ GLSL 着色器基础 ]

上一篇我们用 VAO、VBO 和 EBO 让 GPU 找到了顶点数据,但"找到数据"只是渲染管线的输入阶段。现代 OpenGL 还需要两段运行在 GPU 上的小程序:顶点着色器决定顶点的位置,片元着色器决定片元的颜色。

这篇文章按照"概念讲解 → 官方定义 → 实际代码 → 运行验证 → 常见问题"的方式,完成 GLSL 着色器的第一个闭环:编写 Shader 源码、创建 Shader Object、编译、创建 Program Object、链接、设置 uniform,以及通过 in/out 在管线阶段之间传值。

本文的核心判断:Shader Object 只是编译后的单个着色器,Program Object 才是可以被 glUseProgram 安装到当前管线的完整可执行程序。in/out 负责阶段之间的数据流,顶点属性负责把 VAO 中的数据送进顶点着色器,uniform 负责由 CPU 设置跨顶点和片元的只读参数。

本文使用 C++17、GLFW、GLAD 2 和 OpenGL 3.3 Core Profile。示例默认你已经完成窗口、Context、VAO、VBO 和 EBO 的初始化。


一、Shader 到底在渲染管线中做什么?

OpenGL 不会自动理解"这是一个三角形应该怎么变色"。它只会按照当前 Program 中的 Shader 执行管线。最小的 顶点到像素链路如下:

不同阶段的职责可以这样记:

阶段 输入 输出 本文的任务
Vertex Shader 顶点属性、uniform gl_Position、out 变量 把位置变换到裁剪空间,并传递颜色
图元装配与光栅化 顶点输出 片元输入 组装三角形并对 out 值插值
Fragment Shader in 变量、uniform out 颜色 计算每个片元的最终颜色

Khronos 的 GLSL 3.30 规范把 in、out 和 uniform 定义为不同的接口限定符:in/out 描述阶段边界,uniform 描述由应用程序设置、在一次绘制期间保持一致的变量。

这里有一个容易混淆的词:"顶点属性"在现代 GLSL 里通过顶点着色器的 in 变量接收。 旧教程经常写 attribute,但在 GLSL 3.30 Core Profile 中应使用 in;C++ 侧仍然会使用 glVertexAttribPointer 和 glEnableVertexAttribArray。


二、顶点着色器:每个顶点执行一次

1. 最小顶点着色器

cpp 复制代码
#version 330 core
// 指定GLSL版本为330,对应OpenGL 3.3;core代表核心模式,禁用老旧固定管线API

// layout(location = 0):指定该顶点输入属性的位置编号为0
// in vec3 aPosition:顶点着色器输入变量,接收来自VAO/VBO的顶点三维坐标(x,y,z)
layout (location = 0) in vec3 aPosition;

// layout(location = 1):指定该顶点输入属性的位置编号为1
// in vec3 aColor:顶点着色器输入变量,接收来自VAO/VBO的顶点RGB颜色
layout (location = 1) in vec3 aColor;

// out vec3 vertexColor:顶点着色器输出变量
// 该变量的值不会直接传给片元着色器,会经过光栅化阶段自动插值,再送入片元着色器同名in变量
out vec3 vertexColor;

// 着色器入口函数,每一个顶点都会执行一次main函数
void main()
{
    // gl_Position:顶点着色器内置特殊输出变量,类型vec4齐次坐标,必须赋值
    // 输出裁剪空间坐标,后续管线会自动执行透视除法,转换为NDC标准化设备坐标
    // vec4(aPosition, 1.0):将vec3顶点坐标扩展为齐次坐标,w分量设为1.0
    gl_Position = vec4(aPosition, 1.0);

    // 将当前顶点的颜色赋值给输出变量vertexColor
    // 三角形三个顶点的vertexColor,在光栅化阶段会被插值,三角形内部每个片元得到混合后的颜色
    vertexColor = aColor;
}

这段代码有四个关键点:

  1. #version 330 core 指定 GLSL 版本和 Core Profile 语法;
  2. layout (location = 0) 把 C++ 顶点属性 0 绑定到 aPosition;
  3. gl_Position 是顶点着色器必须写出的内建输出;
  4. vertexColor 是写给下一阶段的用户定义输出。

layout(location=N) 只是给着色器输入变量分配一个属性编号,定义阶段和 VAO 无关 ; 但执行绘制命令时,GPU 要读取 N 号位置的顶点数据,就必须依靠 VAO 来提供 N 号属性槽的数据源配置。

顶点着色器对每个被绘制的顶点执行一次。对于三角形的三个顶点,它会执行三次,而不是对屏幕上的每个像素执行。

2. gl_Position 为什么必须是裁剪空间位置?

在没有矩阵变换的 Hello Triangle 中,输入的坐标已经落在规范化设备坐标附近,因此可以直接写成:

cpp 复制代码
gl_Position = vec4(aPosition, 1.0);

gl_Position 使用四维齐次坐标。顶点经过透视除法后才会进入 NDC 空间,通常要求 x、y、z 落在 [-1, 1] 附近。后面的投影矩阵、视图矩阵和模型矩阵,本质上都是在顶点着色器中改变这一步的输入。

上面这张图想表达的核心,其实就是理解 gl_Position 为什么不是"屏幕坐标",而必须是裁剪空间(Clip Space)的齐次坐标。

说到这里,有必要来知晓一下什么是齐次坐标:

齐次坐标就是用 n+1 个数,来表示 n 维空间里的点 。 我们平时的 3D 空间点 (x,y,z),用齐次坐标写成 (x,y,z,w),4 个分量。

代码里:vec4(aPosition, 1.0) aPosition 是 vec3(x,y,z),补上 w=1.0 → 得到齐次坐标 vec4(x,y,z,1.0)

核心规则:齐次转普通笛卡尔坐标

对于齐次坐标

例子:

  • 等价普通坐标
  • 等价普通坐标

只要 x/w、y/w、z/w 不变,就是同一个三维点。

w 的两种最常见含义

  1. w = 1.0:代表【点】 (x,y,z,1):三维空间里一个位置点。顶点着色器输出顶点位置,一般用 w=1。
  2. w = 0.0:代表【方向向量】 (x,y,z,0):向量,没有起点,不能平移。

向量平移不会改变向量本身,所以 w=0,做平移变换的时候不会被平移影响。

为什么 GPU/OpenGL 非要用齐次坐标?

✅ 把「平移、旋转、缩放」全部统一成矩阵乘法! 普通 3×3 矩阵只能做旋转、缩放,不能做平移。平移是加法。

如果用 4×4 矩阵 + 齐次坐标: 旋转、缩放、平移,全部都可以写成 M * vec4 矩阵乘法。 GPU 硬件天生就是大量做矩阵乘法,非常方便。

✅ 透视投影需要 w 透视投影矩阵算完之后,w 不再等于 1。 GPU 管线会自动做透视除法 :,得到 NDC 标准化设备坐标。

这一步是固定管线,你不用手动写,但是 gl_Position 必须输出 vec4 齐次坐标,管线内部会自动执行透视除法。

和当前代码关联:

cpp 复制代码
gl_Position = vec4(aPosition, 1.0);
  • aPosition:vec3,NDC 空间坐标,本来就在 -1,1 范围
  • 填充 w=1.0,包装成齐次坐标,赋值给内建变量gl_Position
  • 送到管线后续阶段,GPU 自动透视除法(这里 w=1,除以 1,坐标不变)

通俗类比

普通坐标:(x,y,z),像照片上的位置。 齐次坐标:多带一个 w 参数,这个 w 像一个缩放因子; w=1:正常点;w 越大,点越 "远"(透视投影)。


在 Vertex Shader 中:

复制代码
gl_Position = ...;

这里的 gl_Position 必须提供一个用于后续固定功能流水线处理的裁剪空间坐标:

复制代码
Vertex Shader
      │
      │ gl_Position = vec4(x, y, z, w)
      ↓
   裁剪空间
      │
      ↓
   透视除法
      │
      ↓
     NDC
      │
      ↓
   视口变换
      │
      ↓
    屏幕坐标

所以:

Vertex Shader 不负责直接把顶点变成屏幕像素坐标。

它负责把顶点送到裁剪空间。


为什么 gl_Position 是 vec4?

复制代码
gl_Position = vec4(aPosition, 1.0);

假设:

复制代码
aPosition = vec3(0.5, 0.5, 0.0);

那么:

复制代码
aPosition

       x       y       z
       │       │       │
       ▼       ▼       ▼
    ( 0.5 ,  0.5 ,  0.0 )
                    │
                    │ + w
                    ↓
gl_Position = (0.5, 0.5, 0.0, 1.0)

这里的第四个分量:

复制代码
w = 1.0

非常重要。

因为 OpenGL 后面需要进行:

透视除法(Perspective Division)

也就是:

复制代码
x_ndc = x_clip / w
y_ndc = y_clip / w
z_ndc = z_clip / w

因此:

复制代码
Clip Space
(xc, yc, zc, w)

        ↓

NDC
(xc/w, yc/w, zc/w)

为什么不能直接输出 NDC?

这是一个特别容易混淆的地方。

在 Hello Triangle 中:

复制代码
gl_Position = vec4(aPosition, 1.0);

看起来好像:

复制代码
aPosition
   ↓
NDC

但实际上不是。

准确的过程是:

复制代码
aPosition
   ↓
Vertex Shader
   ↓
gl_Position
(0.5, 0.5, 0, 1)
   ↓
Clip Space
   ↓
透视除法
   ↓
(0.5, 0.5, 0)
   ↓
NDC

只是因为:

复制代码
w = 1

所以:

复制代码
x / w = x
y / w = y
z / w = z

数值上看起来没有变化。

因此 Hello Triangle 会让人产生一个错觉:

"gl_Position 就是 NDC。"

实际上不是。


为什么 OpenGL 非要经过 Clip Space?

因为 Clip Space 是 OpenGL 进行裁剪和透视投影的中间坐标系统。

例如:

复制代码
             Clip Space
          ┌──────────────┐
         /│             /│
        / │            / │
       ┌──────────────┐  │
       │  │           │  │
       │  │  Triangle │  │
       │  └───────────│──┘
       │ /            │ /
       │/             │/
       └──────────────┘

GPU 可以先判断:

复制代码
这个顶点是否在可见范围?

x
y
z
w

然后把超出视锥体的部分裁掉。

这就是:

复制代码
Clip

也就是"裁剪空间"这个名字的来源。


Clip Space 和 NDC 到底有什么区别?

这是最重要的一组概念。

Clip Space

Vertex Shader 输出:

复制代码
(xc, yc, zc, w)

是四维齐次坐标。

它还没有进行透视除法。


NDC

经过:

复制代码
x_ndc = xc / w
y_ndc = yc / w
z_ndc = zc / w

之后:

复制代码
(x_ndc, y_ndc, z_ndc)

才是 NDC。

OpenGL 中通常:

复制代码
-1 ≤ x ≤ 1
-1 ≤ y ≤ 1
-1 ≤ z ≤ 1

所以:

复制代码
Clip Space
     │
     │ 除以 w
     ↓
NDC

那 w 到底有什么用?

这就是为什么 OpenGL 不直接使用:

复制代码
vec3

而是:

复制代码
vec4

因为 w 可以让 GPU 表示透视关系。

例如:

复制代码
远处的物体

        △
       / \
      /   \
     /_____\

看起来更小。

而近处:

复制代码
      △
     / \
    /   \
   /_____\

看起来更大。

这种"远小近大"的效果,本质上和:

复制代码
x / w
y / w
z / w

有关。


这就是投影矩阵存在的意义

以后学习:

bash 复制代码
glm::mat4 model;
glm::mat4 view;
glm::mat4 projection;

其实就是在构造:

复制代码
模型空间
   ↓
Model Matrix
   ↓
世界空间
   ↓
View Matrix
   ↓
观察空间
   ↓
Projection Matrix
   ↓
Clip Space

最终:

复制代码
gl_Position =
    projection *
    view *
    model *
    vec4(aPosition, 1.0);

也就是:

复制代码
                M
模型空间 ─────────────→ 世界空间
                       │
                       │ V
                       ↓
                    相机空间
                       │
                       │ P
                       ↓
                    Clip Space
                       │
                       │ /w
                       ↓
                      NDC

所以:

复制代码
gl_Position

其实就是整个 MVP 变换链的最终结果。


为什么 Hello Triangle 可以这么简单?

cpp 复制代码
#version 330 core

layout(location = 0) in vec3 aPosition;

void main()
{
    gl_Position = vec4(aPosition, 1.0);
}

实际上是在说:

"我的顶点现在已经处于一个可以直接作为裁剪空间输入的坐标范围了,所以我不需要 Model、View、Projection 矩阵,直接把它交给后面的流水线。"

例如:

复制代码
float vertices[] = {
     0.0f,  0.5f, 0.0f,
    -0.5f, -0.5f, 0.0f,
     0.5f, -0.5f, 0.0f
};

这些坐标已经在:

复制代码
x ∈ [-1,1]
y ∈ [-1,1]
z ∈ [-1,1]

附近。

所以直接:

复制代码
gl_Position = vec4(aPosition, 1.0);

就够了。


**真正完整的流程是:**现在可以把 OpenGL 3.3 的顶点到屏幕过程记成:

复制代码
             CPU
              │
              │ 顶点数据
              ↓
             VBO
              │
              ↓
             VAO
              │
              │ attribute 0
              ↓
       Vertex Shader
              │
              │
              │ gl_Position
              ↓
       ┌──────────────┐
       │  Clip Space  │
       │ (x,y,z,w)    │
       └──────┬───────┘
              │
              │ 裁剪
              ↓
       Perspective Division
              │
              │ x/w,y/w,z/w
              ↓
       ┌──────────────┐
       │     NDC      │
       │  [-1, 1]     │
       └──────┬───────┘
              │
              ↓
        Viewport Transform
              │
              ↓
        屏幕/窗口坐标
              │
              ↓
        Rasterization
              │
              ↓
        Fragment Shader
              │
              ↓
          Framebuffer
              │
              ↓
            屏幕

可以把每个空间这样理解

空间 主要作用 典型形式
模型空间 物体自己的坐标 (x,y,z)
世界空间 物体放到世界中的位置 (x,y,z)
观察空间 从摄像机角度看世界 (x,y,z)
Clip Space 裁剪 + 为透视除法做准备 (x,y,z,w)
NDC 归一化设备坐标 [-1,1]
屏幕空间 最终窗口像素位置 (pixelX,pixelY)

所以最核心的关系是:

复制代码
模型空间
   ↓ M
世界空间
   ↓ V
观察空间
   ↓ P
Clip Space       ← gl_Position
   ↓ /w
NDC
   ↓ Viewport
屏幕空间

最后抓住一个本质,我们可以把:

复制代码
gl_Position = vec4(aPosition, 1.0);

理解成:

"我告诉 OpenGL:这个顶点经过 Vertex Shader 后,在 GPU 后续渲染流水线中应该处于什么位置。"

而不是:

❌ "把顶点直接画到屏幕的 (x,y)。"

真正的屏幕坐标还要经过:

复制代码
gl_Position
    ↓
Clip
    ↓
Perspective Division
    ↓
NDC
    ↓
Viewport Transform
    ↓
Rasterization
    ↓
Fragment

这也是为什么 gl_Position 是 Vertex Shader 最核心的输出之一 :它不是单纯的一个"位置变量",而是把你的 Shader 计算结果正式接入 OpenGL 后续固定功能流水线的位置接口。

3. 顶点属性如何进入 in 变量?

C++ 侧的 VAO 配置必须和 GLSL 声明对应:

cpp 复制代码
// 顶点数据布局:position(3 float) + color(3 float)
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE,
                      6 * sizeof(float), reinterpret_cast<void*>(0));
glEnableVertexAttribArray(0);

glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE,
                      6 * sizeof(float),
                      reinterpret_cast<void*>(3 * sizeof(float)));
glEnableVertexAttribArray(1);

对应关系是:

cpp 复制代码
C++ location 0  ───────>  GLSL layout(location = 0) in vec3 aPosition
C++ location 1  ───────>  GLSL layout(location = 1) in vec3 aColor

OpenGL 不根据变量名匹配顶点属性。aPosition 这个名字可以改成 position,只要 location 和数据格式一致,仍然可以正确读取。


三、片元着色器:每个片元执行一次

在这里先介绍一下片元是什么:

① 1:顶点数据 Vertex Data

CPU 先提供顶点数据,例如:

cpp 复制代码
float vertices[] = {
    // position          // color
     0.0f,  0.5f, 0.0f,  1.0f, 0.0f, 0.0f,
    -0.5f, -0.5f, 0.0f,  0.0f, 1.0f, 0.0f,
     0.5f, -0.5f, 0.0f,  0.0f, 0.0f, 1.0f
};

也就是:

bash 复制代码
顶点0:位置 + 红色
顶点1:位置 + 绿色
顶点2:位置 + 蓝色

通过 VBO 存储。


② 2:Vertex Shader 顶点着色器

GPU 对每一个顶点分别执行一次:

cpp 复制代码
layout(location = 0) in vec3 aPosition;
layout(location = 1) in vec3 aColor;

out vec3 vertexColor;

void main()
{
    gl_Position = vec4(aPosition, 1.0);
    vertexColor = aColor;
}

这里:

cpp 复制代码
aPosition
   ↓
gl_Position

负责告诉 GPU:

这个顶点最终应该在哪里。

而:

cpp 复制代码
aColor
   ↓
vertexColor

负责把:

这个顶点的颜色继续往后传。

所以此时还是:

复制代码
顶点0 → 红色
顶点1 → 绿色
顶点2 → 蓝色

③ 3:图元装配 + 光栅化

GPU 发现:

复制代码
顶点0
顶点1
顶点2

在:

cpp 复制代码
glDrawElements(GL_TRIANGLES, ...)

或者:

cpp 复制代码
glDrawArrays(GL_TRIANGLES, ...)

的规则下,三个顶点组成一个三角形:

复制代码
          ①
         / \
        /   \
       /     \
      /       \
    ②─────────③

然后进入光栅化。

光栅化器干的事情就是:

把这个数学意义上的三角形,转换成屏幕上需要处理的一堆 Fragment。

例如:

复制代码
          ●
         ●●●
        ●●●●●
       ●●●●●●●
      ●●●●●●●●●

这些 ● 就可以理解为三角形覆盖区域产生的片元位置。


④ 4:颜色插值 + Fragment Shader

三个顶点:

复制代码
        红
         ●
        / \
       /   \
      /     \
     ●───────●
    绿       蓝

GPU 不会简单地让整个三角形都是红、绿、蓝中的某一种。

而是根据每一个 Fragment 在三角形中的位置,自动计算:

cpp 复制代码
当前颜色
=
红色 × 某个比例
+
绿色 × 某个比例
+
蓝色 × 某个比例

所以三角形中间可能得到:

bash 复制代码
红 + 绿 + 蓝

的混合颜色。

这就是:

插值(interpolation)

然后 Fragment Shader 收到:

复制代码
in vec3 vertexColor;

这里的 vertexColor 已经不是简单的"某个顶点原来的颜色"。

而是:

当前这个 Fragment 根据三个顶点颜色插值得到的颜色。

然后:

cpp 复制代码
fragColor = vec4(vertexColor, 1.0);

把它作为这个 Fragment 最终输出的颜色。


⑤ 5:Framebuffer / 屏幕

Fragment Shader 输出:

cpp 复制代码
out vec4 fragColor;

例如:

cpp 复制代码
fragColor = (0.7, 0.2, 0.3, 1.0)

经过后续的测试、混合等操作后,最终写入:

cpp 复制代码
Framebuffer

如果你使用的是默认窗口 Framebuffer,那么最终就表现为:

复制代码
屏幕上的一个像素

因此整个 1~5 可以压缩成:

复制代码
① Vertex Data
        ↓
   顶点位置 + 颜色
        ↓
② Vertex Shader
        ↓
   每个顶点单独处理
        ↓
③ 图元装配 + 光栅化
        ↓
   三个顶点 → 三角形 → 大量 Fragment
        ↓
④ 插值 + Fragment Shader
        ↓
   每个 Fragment 计算颜色
        ↓
⑤ Framebuffer
        ↓
   最终显示到屏幕

最关键的是 ③ → ④:

复制代码
三个顶点的颜色
        ↓
    光栅化阶段
        ↓
     自动插值
        ↓
每一个 Fragment 得到自己的 vertexColor
        ↓
Fragment Shader
        ↓
最终颜色

所以我们看到三角形里面的平滑颜色渐变 ,并不是我们在 Fragment Shader 里面一个像素一个像素手动算出来的,而是 Vertex Shader 输出的 out vertexColor 在光栅化阶段被 GPU 自动插值,然后作为 Fragment Shader 的 in vertexColor 输入。

1. 接收顶点着色器的输出

cpp 复制代码
#version 330 core

in vec3 vertexColor;
out vec4 fragColor;

uniform float brightness;

void main()
{
    fragColor = vec4(vertexColor * brightness, 1.0);
}

顶点着色器中的:

cpp 复制代码
out vec3 vertexColor;

对应片元着色器中的:

cpp 复制代码
in vec3 vertexColor;

这两个变量的类型和接口必须匹配。对于三角形内部的片元,vertexColor 通常是三个顶点颜色经过光栅化阶段插值得到的值。因此三角形会呈现平滑的颜色渐变。

2. 片元和像素不是同一个概念

片元着色器处理的是候选片元。片元经过深度测试、模板测试、混合和写掩码之后,才可能更新 Framebuffer 中的像素。片元着色器输出颜色并不意味着颜色一定最终显示。

3. out 变量可以使用显式 location

OpenGL 3.3 中可以直接指定片元输出位置:

cpp 复制代码
layout (location = 0) out vec4 fragColor;

这样可以明确告诉 OpenGL:这个输出写入当前 Framebuffer 的颜色附件 0。对于默认 Framebuffer 的简单示例,普通的 out vec4 fragColor; 也可以使用,链接器会为它分配输出位置;显式 location 更利于大型程序维护。


四、Shader Object 和 Program Object 的区别

1. Shader Object 是单个阶段的编译结果

创建 Shader Object:

cpp 复制代码
GLuint vertexShader = glCreateShader(GL_VERTEX_SHADER);
GLuint fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);

它们分别代表顶点着色器和片元着色器。此时对象还没有源码,也不能执行。

2. glShaderSource 只提交源码

cpp 复制代码
const char* source = R"GLSL(
    #version 330 core
    void main() { }
)GLSL";

glShaderSource(vertexShader, 1, &source, nullptr);

glShaderSource 把字符串交给 Shader Object。它不会检查语法,也不会马上生成 GPU 可执行代码。

3. glCompileShader 编译单个 Shader

cpp 复制代码
glCompileShader(vertexShader);

GLint compiled = GL_FALSE;
glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &compiled);
if (compiled != GL_TRUE) {
    // 使用 glGetShaderInfoLog 读取编译器日志
}

顶点着色器和片元着色器要分别编译。顶点着色器编译成功,不代表片元着色器也成功;两者都成功,也不代表它们之间的 in/out 接口一定能链接。

4. Program Object 才是完整的可执行程序

cpp 复制代码
GLuint program = glCreateProgram();
glAttachShader(program, vertexShader);
glAttachShader(program, fragmentShader);
glLinkProgram(program);

glLinkProgram 会把已经编译的 Shader 阶段组合起来,检查接口匹配,并生成可以安装到当前 OpenGL 状态的可执行程序。glUseProgram 参考页 对这个关系有明确描述:成功编译并附加到 Program Object、再成功链接之后,Program 才包含可执行代码。

5. Shader 可以删除,Program 仍然可以使用

链接成功后可以删除单独的 Shader Object:

cpp 复制代码
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);

Program 已经拥有链接后的可执行代码。删除 Shader Object 不会破坏 Program;这是常见的资源清理方式。


五、编译、链接和日志检查

1. 通用的 Shader 编译函数

实际开发不要只调用 glCompileShader,却不读取错误日志。下面的函数可以编译任意一个 Shader 阶段:

cpp 复制代码
#include <glad/gl.h>
#include <iostream>
#include <string>

static GLuint compileShader(GLenum type, const char* source)
{
    const GLuint shader = glCreateShader(type);
    if (shader == 0) {
        std::cerr << "glCreateShader failed\n";
        return 0;
    }

    glShaderSource(shader, 1, &source, nullptr);
    glCompileShader(shader);

    GLint success = GL_FALSE;
    glGetShaderiv(shader, GL_COMPILE_STATUS, &success);
    if (success != GL_TRUE) {
        GLint logLength = 0;
        glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &logLength);
        std::string log(static_cast<std::size_t>(logLength), '\0');
        glGetShaderInfoLog(shader, logLength, nullptr, log.data());
        std::cerr << "Shader compile error:\n" << log << '\n';
        glDeleteShader(shader);
        return 0;
    }

    return shader;
}

glGetShaderiv(shader, GL_COMPILE_STATUS, ...) 查询编译是否成功,glGetShaderInfoLog 返回编译器消息。即使某些驱动在警告时仍然返回成功,也应该把日志输出到开发环境中。

2. 通用的 Program 链接函数

cpp 复制代码
static GLuint linkProgram(GLuint vertexShader, GLuint fragmentShader)
{
    const GLuint program = glCreateProgram();
    if (program == 0) {
        std::cerr << "glCreateProgram failed\n";
        return 0;
    }

    glAttachShader(program, vertexShader);
    glAttachShader(program, fragmentShader);
    glLinkProgram(program);

    GLint linked = GL_FALSE;
    glGetProgramiv(program, GL_LINK_STATUS, &linked);
    if (linked != GL_TRUE) {
        GLint logLength = 0;
        glGetProgramiv(program, GL_INFO_LOG_LENGTH, &logLength);
        std::string log(static_cast<std::size_t>(logLength), '\0');
        glGetProgramInfoLog(program, logLength, nullptr, log.data());
        std::cerr << "Program link error:\n" << log << '\n';
        glDeleteProgram(program);
        return 0;
    }

    return program;
}

编译错误和链接错误是两类不同的问题:

错误阶段 常见原因 查询函数
编译 GLSL 语法、类型或版本错误 glGetShaderInfoLog
链接 in/out 类型不匹配、缺少必须阶段 glGetProgramInfoLog
运行 当前状态不适合执行 Program glValidateProgram,以及 OpenGL 调试输出

六、in / out:着色器阶段之间如何传值?

1. 顶点到片元的用户变量

顶点着色器:

cpp 复制代码
out vec3 vertexColor;

void main()
{
    vertexColor = aColor;
    gl_Position = vec4(aPosition, 1.0);
}

片元着色器:

cpp 复制代码
in vec3 vertexColor;
out vec4 fragColor;

void main()
{
    fragColor = vec4(vertexColor, 1.0);
}

这里的 vertexColor 不是一个全局共享变量,也不是 CPU 直接写入的内存。它是两个相邻可编程阶段之间的接口变量。顶点着色器为每个顶点产生一个值,光栅化阶段根据三角形位置进行插值,片元着色器读取每个片元对应的输入值。

2. 插值限定符

默认情况下,浮点 in/out 变量使用平滑插值。需要保持某个顶点的离散值时,可以使用 flat:

cpp 复制代码
// Vertex Shader
flat out int primitiveId;

// Fragment Shader
flat in int primitiveId;

flat 不进行顶点之间的插值,而是使用 provoking vertex 的值。颜色、纹理坐标等连续数据通常使用默认插值;材质编号、对象编号等离散数据才考虑 flat。

OpenGL 默认的 smooth 插值,就是光栅化过程中,根据 Fragment 在三角形中的位置得到重心权重 α、β、γ,然后对三个顶点的 out 属性进行加权插值;而 flat 则禁止这种插值,让 Fragment 直接继承图元指定顶点的离散值。

3. 接口匹配失败会在链接阶段暴露

下面这种写法类型不匹配:

cpp 复制代码
// Vertex Shader
out vec3 vertexColor;

// Fragment Shader
in vec4 vertexColor;

它们虽然变量名相同,但类型不同,Program 链接会失败。接口匹配应至少保证:

  • 类型和数组维度一致;
  • 输入输出的 location 或名称能够对应;
  • 插值和相关限定符符合使用场景。

OpenGL 3.3 使用名称匹配用户定义的阶段接口时,最稳妥的做法是保持变量名、类型和限定符一致;不要依赖"看起来差不多"的隐式猜测。

4. location:为什么有时候写,有时候不写?

前面我们有看到这样的代码:

cpp 复制代码
// Vertex Shader
out vec3 vertexColor;

// Fragment Shader
in vec3 vertexColor;

细心的同志可能会发现一个问题:

这里明明没有写 location = 0、location = 1 之类的东西,那 OpenGL 到底怎么知道 vertexColor 对应哪个位置?

这就涉及 GLSL 中的 location 布局限定符。

location 本质上是在指定"接口槽位"

例如顶点着色器中:

cpp 复制代码
layout(location = 0) in vec3 aPosition;
layout(location = 1) in vec3 aColor;

这里的:

复制代码
location = 0
location = 1

可以理解成:

直接告诉 OpenGL:这个输入变量绑定到哪个 attribute location。

因此:

复制代码
CPU / VAO
   │
   ├── location 0 ──→ aPosition
   │
   └── location 1 ──→ aColor

于是 CPU 侧就可以非常明确地:

cpp 复制代码
glVertexAttribPointer(
    0,              // location
    3,
    GL_FLOAT,
    GL_FALSE,
    sizeof(Vertex),
    (void*)0
);

glEnableVertexAttribArray(0);

对应到 GLSL:

cpp 复制代码
layout(location = 0) in vec3 aPosition;

也就是说,两边实际上通过 location = 0 建立了对应关系。


那么不写 location 可以吗?

这肯定是可以的,否则上面写的不就是错误的了。

例如:

cpp 复制代码
in vec3 aPosition;
in vec3 aColor;

我们没有告诉 GLSL:

复制代码
aPosition → 0
aColor    → 1

这时候就可以让 OpenGL 在 Program Link 阶段进行接口 location 的分配。

程序链接完成以后,CPU 可以通过 API 查询:

cpp 复制代码
GLint location;

location = glGetAttribLocation(program, "aPosition");

以及:

cpp 复制代码
location = glGetAttribLocation(program, "aColor");

OpenGL 会返回这个变量最终对应的 attribute location。

因此可以理解成:

复制代码
                Program Link
                     │
                     ▼
          ┌────────────────────┐
          │ OpenGL 分配 location │
          └────────────────────┘
                 │       │
                 ▼       ▼
             aPosition  aColor
                 │       │
              location location
                 │       │
                 ▼       ▼
                  0       1

具体分配的 index 成多少,不要自己假设 ,应该通过 glGetAttribLocation() 查询。


两种方式其实就是两种"指定 location"的策略

所以我们可以把它们放在一起看:

方式一:GLSL 中显式指定

cpp 复制代码
layout(location = 0) in vec3 aPosition;
layout(location = 1) in vec3 aColor;

相当于:

复制代码
程序员
  │
  │ 直接指定
  ▼
location 0 → aPosition
location 1 → aColor

CPU 侧就可以直接使用:

复制代码
glEnableVertexAttribArray(0);
glEnableVertexAttribArray(1);

这种方式非常直观。


方式二:不指定,让 OpenGL 在链接时分配

cpp 复制代码
in vec3 aPosition;
in vec3 aColor;

然后:

cpp 复制代码
GLint positionLoc =
    glGetAttribLocation(program, "aPosition");

GLint colorLoc =
    glGetAttribLocation(program, "aColor");

变成:

复制代码
             Program Link
                  │
                  ▼
          OpenGL 分配 location
             │          │
             ▼          ▼
        aPosition     aColor
             │          │
             ▼          ▼
           loc=?       loc=?
             │          │
             └────┬─────┘
                  │
                  ▼
        glGetAttribLocation()
                  │
                  ▼
              CPU 获取

所以:

不写 location 并不意味着这个变量没有 location,而是 location 的确定交给了 OpenGL。


location 和变量名不是一回事

这里还有一个特别容易混淆的地方。

例如:

cpp 复制代码
layout(location = 0) in vec3 aPosition;

这里其实存在两个不同的东西:

复制代码
aPosition
   │
   └── 变量名

location = 0
   │
   └── 接口位置

变量名是 GLSL 中使用的名字:

复制代码
aPosition

而 location 是 OpenGL 用来标识这个接口槽位的编号:

复制代码
0

所以 CPU 端:

复制代码
glVertexAttribPointer(0, ...);

并不是在告诉 OpenGL:

"我要操作叫 aPosition 的变量。"

而是在告诉 OpenGL:

"我要给 vertex attribute location 0 提供数据。"

而:

复制代码
layout(location = 0) in vec3 aPosition;

则告诉着色器:

"location 0 的输入数据,在 GLSL 中叫 aPosition。"

两者就连起来了。


in/out 接口也可以使用 location

前面我们讲的是:

复制代码
layout(location = 0) in vec3 aPosition;

这是顶点属性输入。

而 shader 阶段之间的 in/out 接口同样可以显式指定 location。

例如:

cpp 复制代码
// Vertex Shader
layout(location = 0) out vec3 vertexColor;

void main()
{
    vertexColor = aColor;
    gl_Position = vec4(aPosition, 1.0);
}

片元着色器:

cpp 复制代码
// Fragment Shader
layout(location = 0) in vec3 vertexColor;

layout(location = 0) out vec4 fragColor;

void main()
{
    fragColor = vec4(vertexColor, 1.0);
}

这里:

复制代码
Vertex Shader
      │
      │ location 0
      ▼
  vertexColor
      │
      │ 光栅化 + 插值
      ▼
Fragment Shader
      │
      │ location 0
      ▼
  vertexColor

这样就可以显式指定两个阶段之间的接口槽位,不需要依赖变量名进行匹配。


所以 location 可以理解成"接口编号"

把前面的内容统一起来,会更加容易理解:

复制代码
                  OpenGL Shader Pipeline
                         │
        ┌────────────────┼────────────────┐
        │                │                │
        ▼                ▼                ▼
   Vertex Shader     Rasterizer      Fragment Shader
        │                                 │
        │ location 0                      │ location 0
        └──────────────→ vertexColor ─────┘
                         │
                       插值
                         │
                         ▼
                    Fragment Shader

因此 location 本质上就是一个接口槽位编号。

它解决的是:

"这个数据从哪个接口槽位进来/出去?"

而不是:

"这个变量叫什么名字?"


为什么现代 OpenGL 经常推荐显式写 location?

因为这样可以减少 CPU 和 shader 之间的"查找"。

例如:

cpp 复制代码
layout(location = 0) in vec3 aPosition;
layout(location = 1) in vec3 aColor;
layout(location = 2) in vec2 aTexCoord;

CPU 端直接约定:

复制代码
0 → Position
1 → Color
2 → TexCoord

然后:

cpp 复制代码
glVertexAttribPointer(0, ...);
glVertexAttribPointer(1, ...);
glVertexAttribPointer(2, ...);

整个接口关系在代码中一眼就能看到。

如果不写:

cpp 复制代码
in vec3 aPosition;
in vec3 aColor;
in vec2 aTexCoord;

那么就需要在 Program Link 后:

cpp 复制代码
glGetAttribLocation(program, "aPosition");
glGetAttribLocation(program, "aColor");
glGetAttribLocation(program, "aTexCoord");

再把查询到的 location 保存下来使用。

所以现代 OpenGL 中经常会看到:

复制代码
layout(location = 0) ...
layout(location = 1) ...
layout(location = 2) ...

这实际上是在显式建立 shader 与 CPU/VAO 之间的接口契约。


但要注意:glGetAttribLocation 和 glGetUniformLocation 不是一回事

这一点也很重要。

对于:

cpp 复制代码
in vec3 aPosition;

查询的是:

cpp 复制代码
glGetAttribLocation(program, "aPosition");

而对于:

cpp 复制代码
uniform mat4 model;

查询的是:

cpp 复制代码
glGetUniformLocation(program, "model");

因为它们属于不同的接口体系:

复制代码
Shader Program
│
├── Vertex Attribute
│      └── glGetAttribLocation()
│
├── Uniform
│      └── glGetUniformLocation()
│
└── Shader Stage Interface
       └── in / out

所以看到 location 的时候,首先要看它到底是在描述:

Vertex Attribute、Fragment Output,还是 Shader Stage Interface。

layout(location = N) 是 GLSL 中显式指定接口 location 的布局限定符。写了它,就是程序员直接规定"这个变量占用哪个接口槽位";不写,则可以让 OpenGL 在 Program Link 阶段确定 location,并通过 glGetAttribLocation() 等 API 查询。


七、uniform:CPU 传给 Shader 的只读参数

1. uniform 的语义

uniform 是 Shader 中由应用程序设置的全局变量:

cpp 复制代码
uniform float brightness;
uniform vec3 tintColor;

它在一次绘制调用期间对所有顶点或片元保持相同。模型矩阵、视图矩阵、投影矩阵、时间、光源参数和材质颜色都常常通过 uniform 传入。

与顶点属性的区别可以这样理解:

传值方式 数据变化频率 典型内容
顶点属性 in 每个顶点不同 位置、颜色、法线、UV
阶段接口 out → in 每个顶点产生,片元阶段插值读取 顶点颜色、纹理坐标
uniform 一次绘制期间通常不变【伴随外部自由设置来变化】 变换矩阵、时间、材质参数

2. 获取 uniform location

Uniform 的 location 在 Program 成功链接之前并不知道:

cpp 复制代码
glUseProgram(program);
const GLint brightnessLocation =
    glGetUniformLocation(program, "brightness");

glGetUniformLocation 的规则 是:必须在 Program 成功链接之后查询;如果变量不存在或被编译器判定为未使用,可能返回 -1。

3. 设置不同类型的 uniform

复制代码
glUseProgram(program);

glUniform1f(brightnessLocation, 0.85f);
glUniform3f(tintLocation, 1.0f, 0.9f, 0.8f);

函数后缀要和 GLSL 类型匹配:

GLSL 类型 C++ 设置函数
float glUniform1f
vec2 / vec3 / vec4 glUniform2f / glUniform3f / glUniform4f
int / bool glUniform1i
ivec2 / ivec3 / ivec4 glUniform2i / glUniform3i / glUniform4i
mat4 glUniformMatrix4fv

官方 glUniform 参考页 特别强调:glUniform 修改的是当前 glUseProgram 安装的 Program,数据类型后缀也必须和 Shader 中的 uniform 类型对应。

4. 为什么 glUniform 前必须 glUseProgram?

复制代码
glUseProgram(program);
glUniform1f(brightnessLocation, 0.85f);

glUniform 不接收 Program Object 句柄,它修改的是当前使用中的 Program。若先设置了另一个 Program,或者根本没有调用 glUseProgram,uniform 就不会写入你以为的对象。

更稳妥的做法是把"绑定 Program"和"设置它的 uniform"放在同一段代码中:

cpp 复制代码
glUseProgram(program);
glUniform1f(brightnessLocation, brightness);
glUniform3f(tintLocation, 1.0f, 0.8f, 0.6f);
glBindVertexArray(vao);
glDrawElements(GL_TRIANGLES, 3, GL_UNSIGNED_INT, nullptr);

八、核心示例:渐变三角形 + uniform 亮度

下面的示例包含 Shader 源码、编译、链接、uniform 设置和绘制。窗口、GLAD、VAO、VBO、EBO 的初始化沿用前面的文章;为了突出 GLSL,这里把 Shader 相关代码和一次绘制调用完整列出。

cpp 复制代码
#include <glad/gl.h>
#include <GLFW/glfw3.h>

#include <cstdlib>
#include <iostream>
#include <string>

/**
 * @brief 编译单个着色器(顶点着色器/片段着色器)
 * @param type 着色器类型:GL_VERTEX_SHADER / GL_FRAGMENT_SHADER
 * @param source GLSL 源码字符串
 * @return 成功返回着色器对象ID;失败返回0
 */
static GLuint compileShader(GLenum type, const char* source)
{
    // 创建着色器对象,返回OpenGL内部对象名称ID
    const GLuint shader = glCreateShader(type);
    // 将GLSL源码挂载到着色器对象上
    // 参数:着色器、源码字符串数量、源码数组、字符串长度数组(nullptr表示自动以'\0'判断长度)
    glShaderSource(shader, 1, &source, nullptr);
    // 提交源码给GPU驱动进行编译
    glCompileShader(shader);

    GLint success = GL_FALSE;
    // 获取着色器编译状态
    glGetShaderiv(shader, GL_COMPILE_STATUS, &success);
    if (success != GL_TRUE)
    {
        // 获取错误日志字符长度
        GLint length = 0;
        glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &length);
        // 分配字符串空间,存储编译日志
        std::string log(static_cast<std::size_t>(length), '\0');
        // 读取着色器编译错误信息
        glGetShaderInfoLog(shader, length, nullptr, log.data());
        // 控制台打印错误日志
        std::cerr << log << '\n';
        // 编译失败,释放着色器资源,防止内存泄漏
        glDeleteShader(shader);
        return 0;
    }
    return shader;
}

/**
 * @brief 创建并链接着色器程序(Program)
 * 内部定义顶点着色器、片段着色器源码,编译后链接成可在GPU执行的程序
 * @return 成功返回program对象ID;编译/链接失败返回0
 */
static GLuint createProgram()
{
    // 顶点着色器源码:负责顶点坐标变换、顶点属性传递
    constexpr const char* vertexSource = R"GLSL(
        #version 330 core
        layout (location = 0) in vec3 aPosition; // 顶点位置属性,绑定索引0
        layout (location = 1) in vec3 aColor;    // 顶点颜色属性,绑定索引1
        out vec3 vertexColor;                    // 输出颜色,传递给片段着色器
        void main()
        {
            gl_Position = vec4(aPosition, 1.0);   // 内建变量:顶点裁剪空间坐标
            vertexColor = aColor;                 // 将顶点颜色传给片段着色器
        }
    )GLSL";

    // 片段着色器源码:负责每个像素(片元)颜色计算
    constexpr const char* fragmentSource = R"GLSL(
        #version 330 core
        in vec3 vertexColor;                     // 接收顶点着色器传过来的插值颜色
        out vec4 fragColor;                      // 输出最终像素颜色
        uniform float brightness;                // uniform全局变量,CPU可动态传入亮度值
        void main()
        {
            fragColor = vec4(vertexColor * brightness, 1.0); // 颜色乘以亮度,Alpha固定1不透明
        }
    )GLSL";

    // 编译顶点着色器
    const GLuint vs = compileShader(GL_VERTEX_SHADER, vertexSource);
    // 编译片段着色器
    const GLuint fs = compileShader(GL_FRAGMENT_SHADER, fragmentSource);
    // 任意一个着色器编译失败,直接返回
    if (vs == 0 || fs == 0) return 0;

    // 创建着色器程序容器,用来挂载着色器并链接
    const GLuint program = glCreateProgram();
    // 将顶点着色器附加到程序
    glAttachShader(program, vs);
    // 将片段着色器附加到程序
    glAttachShader(program, fs);
    // 链接程序:把多个着色器组装成完整GPU执行管线
    glLinkProgram(program);

    GLint linked = GL_FALSE;
    // 获取程序链接状态
    glGetProgramiv(program, GL_LINK_STATUS, &linked);
    if (linked != GL_TRUE)
    {
        // 获取链接日志长度
        GLint length = 0;
        glGetProgramiv(program, GL_INFO_LOG_LENGTH, &length);
        std::string log(static_cast<std::size_t>(length), '\0');
        // 读取链接错误日志
        glGetProgramInfoLog(program, length, nullptr, log.data());
        std::cerr << "Program link error:\n" << log << '\n';
        // 链接失败,释放program资源
        glDeleteProgram(program);
        return 0;
    }

    // 链接完成后,着色器对象可以释放,program已经持有编译后的代码
    glDeleteShader(vs);
    glDeleteShader(fs);
    return program;
}

/**
 * @brief 渲染循环:持续绘制三角形
 * @param window GLFW窗口句柄
 * @param vao 顶点数组对象ID,保存顶点属性配置
 * @param program 着色器程序ID
 */
static void renderTriangle(GLFWwindow* window, GLuint vao, GLuint program)
{
    // 获取uniform变量 brightness 在着色器里的位置索引
    // 注意:不要把glGetUniformLocation写在while循环内部,会重复查询浪费性能
    const GLint brightnessLocation =
        glGetUniformLocation(program, "brightness");

    // 主渲染循环:窗口未收到关闭信号就持续渲染
    while (!glfwWindowShouldClose(window))
    {
        // 设置清屏颜色(背景色)RGBA
        glClearColor(0.08f, 0.10f, 0.16f, 1.0f);
        // 清空颜色缓冲
        glClear(GL_COLOR_BUFFER_BIT);

        // 激活使用我们的着色器程序
        glUseProgram(program);
        // 设置uniform亮度值 0.85
        glUniform1f(brightnessLocation, 0.85f);
        // 绑定VAO,启用顶点属性配置
        glBindVertexArray(vao);
        // 绘制:使用索引绘制三角形,3个顶点,索引类型uint,索引数据nullptr代表使用当前绑定EBO
        glDrawElements(GL_TRIANGLES, 3, GL_UNSIGNED_INT, nullptr);

        // 双缓冲:交换前后缓冲区,把绘制好的画面显示到屏幕
        glfwSwapBuffers(window);
        // 轮询窗口事件(键盘、鼠标、关闭事件)
        glfwPollEvents();
    }
}

这段代码中,window 和 vao 是前文初始化得到的对象。把它接入完整工程时,需要保留对应的 GLFW 事件循环和 VAO/EBO 清理代码。

如果希望每帧改变亮度,可以把 uniform 设置放入循环:

cpp 复制代码
const float brightness =
    0.65f + 0.35f * static_cast<float>(std::sin(glfwGetTime()));

glUseProgram(program);
glUniform1f(brightnessLocation, brightness);

此时需要包含:

cpp 复制代码
#include <cmath>

uniform 的值由 CPU 每帧更新,但同一次 glDrawElements 中的所有顶点和片元都会看到同一个 brightness 值。

这里还是补充一下:

这里的 glUniform1f 函数名看起来比较特殊,这是因为 OpenGL 的传统 API 是 C 风格的,C 语言本身没有 C++ 那样的函数重载,所以 OpenGL 需要把"参数数量"和"参数类型"直接编码到函数名里面。

可以把 glUniform1f 拆成 glUniform + 1 + f:glUniform 表示"设置一个 Shader 中的 uniform",数字 1 表示这次传入 1 个数据分量 ,最后的 f 表示这个数据的类型是 float ,所以 glUniform1f 就是"设置 1 个 float";

同理,glUniform2f 就是 2 个 float,glUniform3f 就是 3 个 float,glUniform4f 就是 4 个 float,而 glUniform1i、glUniform3i 中的 i 则表示 int。

因此其实不需要把这些函数一个个死记,可以记住一个非常通用的规则:glUniform + 数量 + 类型,数字看有几个值,字母看是什么类型。 例如 GLSL 中的 uniform float brightness 对应 glUniform1f,uniform vec3 color 对应 glUniform3f,uniform vec4 color 对应 glUniform4f。这就是 OpenGL 为了在没有函数重载的 C API 中区分不同参数类型和数量而采用的命名方式,所以以后看到一个陌生的 glUniform 函数时,直接拆它的后缀,通常就能理解它是干什么的,而不需要把函数名当成一个整体去背。【QT 中其实有封装好这些,变得更加方便我们使用,但是学习这个,上手 QT 的 OpenGL 就是小儿科了】

GLSL 类型 含义 OpenGL 函数 如何记
float 1 个 float glUniform1f 1 + f
vec2 2 个 float glUniform2f 2 + f
vec3 3 个 float glUniform3f 3 + f
vec4 4 个 float glUniform4f 4 + f
int 1 个 int glUniform1i 1 + i
ivec2 2 个 int glUniform2i 2 + i
ivec3 3 个 int glUniform3i 3 + i
ivec4 4 个 int glUniform4i 4 + i
uint 1 个无符号整数 glUniform1ui 1 + ui
uvec2 2 个无符号整数 glUniform2ui 2 + ui
uvec3 3 个无符号整数 glUniform3ui 3 + ui
uvec4 4 个无符号整数 glUniform4ui 4 + ui
mat2 2×2 float 矩阵 glUniformMatrix2fv Matrix + 2 + f + v
mat3 3×3 float 矩阵 glUniformMatrix3fv Matrix + 3 + f + v
mat4 4×4 float 矩阵 glUniformMatrix4fv Matrix + 4 + f + v

九、attribute、in 和 out 的关系

1. 旧关键字和现代写法

旧版 GLSL 教程可能写成:

cpp 复制代码
attribute vec3 position;
varying vec3 color;

在现代 OpenGL 3.3 Core Profile 中,应改为:

cpp 复制代码
in vec3 position;       // 顶点着色器输入
out vec3 color;         // 顶点着色器输出
in vec3 color;          // 片元着色器输入
out vec4 fragColor;     // 片元着色器输出

可以建立下面的迁移关系:

旧式 GLSL 术语 现代 Core Profile 对应 说明
attribute 顶点着色器中的 in 从 VAO 接收每顶点数据
varying 顶点 out → 片元 in 在阶段之间传递并通常插值
gl_FragColor 用户定义的片元 out 明确声明输出变量

因此,用户所说的"attribute 传值",在本文的 Core Profile 语境中,实际就是 C++ 顶点属性 → 顶点着色器 in。

2. 使用 glBindAttribLocation 的情况

如果不在 GLSL 中写 layout(location = ...),也可以在链接前由 C++ 指定:

Go 复制代码
glBindAttribLocation(program, 0, "aPosition");
glBindAttribLocation(program, 1, "aColor");
glLinkProgram(program);

注意它必须发生在 glLinkProgram 之前。使用显式 layout 时,不需要再调用 glBindAttribLocation;两种方式选一种即可。


十、常见问题:为什么 Shader 没有生效?

1. 只编译,没有链接

cpp 复制代码
glCompileShader(vertexShader);
// 忘记 glAttachShader + glLinkProgram

Shader Object 不能直接被 glUseProgram 使用。必须把编译成功的 Shader 附加到 Program 并成功链接。

2. 只检查编译日志,不检查链接日志

顶点和片元 Shader 可以各自编译成功,但 in/out 类型不匹配仍然会导致 Program 链接失败。必须同时检查:

cpp 复制代码
glGetShaderiv(shader, GL_COMPILE_STATUS, &success);
glGetProgramiv(program, GL_LINK_STATUS, &success);

3. uniform location 在链接前查询

错误顺序:

cpp 复制代码
GLint location = glGetUniformLocation(program, "brightness");
glLinkProgram(program);

正确顺序:

cpp 复制代码
glLinkProgram(program);
GLint location = glGetUniformLocation(program, "brightness");

4. uniform 名字存在,但 location 是 -1

如果 Shader 中声明了 uniform 却从未参与最终输出,编译器可能把它优化掉:

cpp 复制代码
uniform float brightness; // 没有在 main 中使用

这种情况下 glGetUniformLocation 返回 -1 是正常的。先确认变量确实参与了计算,再检查名字是否拼写一致。

5. glUniform 类型不匹配

cpp 复制代码
uniform float brightness;

应使用:

cpp 复制代码
glUniform1f(location, 0.85f);

不要使用 glUniform1i 给它传值。整数、浮点数、矩阵的 API 后缀必须匹配 GLSL 类型。

6. 忘记 glUseProgram

cpp 复制代码
glUniform1f(location, 0.85f); // 当前没有正确 Program

glUniform 修改的是当前安装的 Program。绘制前也必须再次确认:

cpp 复制代码
glUseProgram(program);
glBindVertexArray(vao);
glDrawElements(...);

7. in/out 类型不一致

cpp 复制代码
// Vertex Shader
out vec3 color;

// Fragment Shader
in vec4 color;

这种接口无法正确匹配。名称、类型、数组维度和相关限定符都要保持一致。


十一、把 Shader 生命周期记成一条线

可以把一次 Shader 使用过程压缩成下面这张表:

阶段 OpenGL 对象或函数 结果
1 glCreateShader 创建单个 Shader Object
2 glShaderSource 提交 GLSL 源码
3 glCompileShader 编译一个 Shader 阶段
4 glGetShaderiv / glGetShaderInfoLog 检查编译结果
5 glCreateProgram 创建 Program Object
6 glAttachShader 把阶段附加到 Program
7 glLinkProgram 检查接口并生成可执行程序
8 glGetProgramiv / glGetProgramInfoLog 检查链接结果
9 glUseProgram 将 Program 安装为当前程序
10 glGetUniformLocation + glUniform* 设置 Shader 参数
11 glDraw* 使用当前 Program 执行绘制

Shader 源码只是文本,Shader Object 是单阶段编译结果,Program Object 才是可以参与绘制的组合结果。把这三个层次混在一起,是 GLSL 入门最常见的理解错误。


十二、这一篇真正应该记住什么?

  1. 顶点着色器逐顶点执行 ,必须写出 gl_Position;
  2. 片元着色器逐片元执行 ,通过 out 输出颜色;
  3. 顶点属性通过顶点 Shader 的 in 接收 ,layout(location) 要和 VAO 配置对应;
  4. out → in 是相邻 Shader 阶段之间的接口,颜色和纹理坐标通常会被插值;
  5. uniform 由 CPU 设置,在一次绘制期间对所有执行实例保持一致;
  6. Shader 必须先编译,Program 必须再链接;
  7. glUseProgram 安装的是 Program,不是单独的 Shader Object;
  8. 任何编译或链接失败,都先读日志,不要靠猜。

下一篇继续加入模型矩阵、视图矩阵和投影矩阵时,流程不会改变:矩阵作为 uniform 传入,顶点位置作为 in 传入,顶点着色器在 gl_Position 中完成变换,片元着色器继续负责颜色输出。


官方资料与延伸阅读

附录

完整 main.cpp 快速通道

cpp 复制代码
#include <glad/gl.h>
#include <GLFW/glfw3.h>

#include <iostream>
#include <string>
#include <cmath>

static GLuint compileShader(GLenum type, const char* source) {
    const GLuint shader = glCreateShader(type);
    glShaderSource(shader, 1, &source, nullptr);
    glCompileShader(shader);

    GLint sucess = GL_FALSE;
    glGetShaderiv(shader, GL_COMPILE_STATUS, &sucess);
    if (sucess == GL_FALSE) {
        GLint length = 0;
        glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &length);
        std::string log(static_cast<std::size_t>(length), '\0');
        glGetShaderInfoLog(shader, length, nullptr, log.data());
        std::cout << log.c_str() << std::endl;
        glDeleteShader(shader);
        return 0;
    }

    return shader;
}

static GLuint createProgram() {
    // 顶点着色器源码:负责顶点坐标变换、顶点属性传递
    constexpr const char* vertexSource = R"GLSL(
        #version 330 core
        layout (location = 0) in vec3 aPosition; // 顶点位置属性,绑定索引0
        layout (location = 1) in vec3 aColor;    // 顶点颜色属性,绑定索引1
        out vec3 vertexColor;                    // 输出颜色,传递给片段着色器
        void main()
        {
            gl_Position = vec4(aPosition, 1.0);   // 内建变量:顶点裁剪空间坐标
            vertexColor = aColor;                 // 将顶点颜色传给片段着色器
        }
    )GLSL";

    // 片段着色器源码:负责每个像素(片元)颜色计算
    constexpr const char* fragmentSource = R"GLSL(
        #version 330 core
        in vec3 vertexColor;                     // 接收顶点着色器传过来的插值颜色
        out vec4 fragColor;                      // 输出最终像素颜色
        uniform float brightness;                // uniform全局变量,CPU可动态传入亮度值
        void main()
        {
            fragColor = vec4(vertexColor * brightness, 1.0); // 颜色乘以亮度,Alpha固定1不透明
        }
    )GLSL";

    GLuint vertexShader = compileShader(GL_VERTEX_SHADER, vertexSource);
    GLuint fragmentShader = compileShader(GL_FRAGMENT_SHADER, fragmentSource);
    if (vertexShader == 0 || fragmentShader == 0) return 0;

    GLuint program = glCreateProgram();
    glAttachShader(program, vertexShader);
    glAttachShader(program, fragmentShader);
    glLinkProgram(program);

    GLint success = GL_FALSE;
    glGetProgramiv(program, GL_LINK_STATUS, &success);
    if (success == GL_FALSE) {
        GLint length = 0;
        glGetProgramiv(program, GL_INFO_LOG_LENGTH, &length);
        std::string log(static_cast<std::size_t>(length), '\0');
        glGetProgramInfoLog(program, length, nullptr, log.data());
        std::cout << log.c_str() << std::endl;
        glDeleteProgram(program);
        return 0;
    }

    glDeleteShader(vertexShader);
    glDeleteShader(fragmentShader);

    return program;
}

static void renderTriangle(GLFWwindow* window, GLuint vao, GLuint program) {
    const GLint brightnessLocation = glGetUniformLocation(program, "brightness");

    while (!glfwWindowShouldClose(window)) {
        glClearColor(0.08f, 0.10f, 0.16f, 1.0f);
        glClear(GL_COLOR_BUFFER_BIT);

        glUseProgram(program);
        const float brightness =
            0.65f + 0.35f * static_cast<float>(std::sin(glfwGetTime()));
        glUniform1f(brightnessLocation, brightness);
        glBindVertexArray(vao);
        glDrawElements(GL_TRIANGLES, 3, GL_UNSIGNED_INT, nullptr);

        glfwSwapBuffers(window);
        glfwPollEvents();
    }
}

static void framebufferSizeCallback(GLFWwindow* window, int width, int height) {
    (void)window;
    glViewport(0, 0, width, height);
}

int main() {
    if (!glfwInit()) {
        std::cout << "Failed to initializglfw" << std::endl;
        return EXIT_FAILURE;
    }

    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
    glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);

    GLFWwindow* window = glfwCreateWindow(800, 600, "OpenGL Uniform Program", nullptr, nullptr);
    if (!window) {
        std::cout << "Failed to create GLFW window" << std::endl;
        glfwTerminate();
        return EXIT_FAILURE;
    }

    glfwMakeContextCurrent(window);
    if (gladLoadGL(reinterpret_cast<GLADloadfunc>(glfwGetProcAddress)) == 0) {
        std::cout << "Failed to initializglad" << std::endl;
        glfwDestroyWindow(window);
        glfwTerminate();
        return EXIT_FAILURE;
    }

    glfwSetFramebufferSizeCallback(window, framebufferSizeCallback);
    glViewport(0, 0, 800, 600);

    GLuint program = createProgram();
    if (program == 0) {
        std::cout << "Failed to create program" << std::endl;
        glfwDestroyWindow(window);
        glfwTerminate();
        return EXIT_FAILURE;
    }

    const float vertices[] = {
        // position              // color
        0.0f,  0.6f, 0.0f,       1.0f, 0.2f, 0.2f,
       -0.6f, -0.5f, 0.0f,       0.2f, 1.0f, 0.2f,
        0.6f, -0.5f, 0.0f,       0.2f, 0.3f, 1.0f,
    };
    // 索引数组:使用索引绘制,0,1,2组成一个三角形
    const unsigned int indices[] = { 0, 1, 2 };

    GLuint vao = 0;
    GLuint vbo = 0;
    GLuint ebo = 0;
    glGenVertexArrays(1, &vao);
    glGenBuffers(1, &vbo);
    glGenBuffers(1, &ebo);

    glBindVertexArray(vao);
    glBindBuffer(GL_ARRAY_BUFFER, vbo);
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
    glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), reinterpret_cast<void*>(0));
    glEnableVertexAttribArray(0);

    glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), reinterpret_cast<void*>(sizeof(float) * 3));
    glEnableVertexAttribArray(1);

    glBindBuffer(GL_ARRAY_BUFFER, 0);
    glBindVertexArray(0);

    // 可选:开启垂直同步
    glfwSwapInterval(1);

    // 绘制
    renderTriangle(window, vao, program);

    // ========== 资源释放阶段 ==========
    glDeleteVertexArrays(1, &vao);
    glDeleteBuffers(1, &vbo);
    glDeleteBuffers(1, &ebo);
    glDeleteProgram(program);
    glfwDestroyWindow(window);
    glfwTerminate();

    return EXIT_SUCCESS;
}
相关推荐
李小白杂货铺2 天前
“正在编译着色器“ 耗时原理解析
着色器·技术原理·技术解析·原理解析·编译着色器·正在编译着色器·耗时原理
爱上网的小舟20 天前
着色器缓存大小设置多少合适?N卡A卡缓存路径与清理方法
缓存·着色器
灵境(虚幻知音)1 个月前
WebGL 不支持>1px的线绘制,那 Cesium 的带宽度的线是怎么画出来的?
webgl·cesium·着色器
破竹151 个月前
OpenGL ES调试汇总
图形渲染·着色器
小小数媒成员2 个月前
合并阶段和计算着色器【图文解释】
着色器
小小数媒成员2 个月前
顶点,几何,片元着色器+曲面细分阶段【图文解析】
着色器
SNAKEpc121382 个月前
OpenGL(十六)- 着色器统一值
c语言·c++·线性代数·算法·矩阵·图形渲染·着色器
SNAKEpc121382 个月前
OpenGL(十五)- 着色器语言GLSL
c语言·c++·线性代数·算法·矩阵·图形渲染·着色器
Li.map3 个月前
CesiumJS 地图主题系统:用着色器打造实时滤镜
javascript·arcgis·cesium·着色器