GLSL 数据接口、Uniform 与 Shader Class

GLSL 数据接口、Uniform 与 Shader Class:保留 LearnOpenGL 3.3 Core 正文机制,以 context—资源—结果合同、GPU 轨迹和章专属单故障完成可重放验收。

学习目标

  • 能解释 attributes、uniforms 与 stage out/in 三种数据通道的生命周期
  • 能判断 scalar、vector、matrix 与 sampler 类型对应的 GLSL 运算和 CPU 上传函数
  • 能推导三角形内部 varying 的插值来源并控制 flat 或平滑语义
  • 能实现颜色与时间 uniform,缓存 location 并在正确 program 上逐帧更新
  • 能设计 Shader class 的文件读取、compile/link 日志、use 与资源释放

为什么 GPU 需要两张能对接的「食谱」

上一章你已经把三角形画了出来,靠的是两段小程序:一段管「每个角摆在哪」,一段管「每个点染成什么色」。这一章我们把镜头拉近,看清这两段小程序内部到底怎么写

可以把它们想成给 GPU 的两张食谱:你不用亲手炒每一道菜(GPU 有成千上万个小厨师同时开火),你只负责把菜谱写清楚——「拿到这份原料,照这几步做,端出这个成品」。GPU 照着菜谱,对每一个顶点、每一个像素各跑一遍。

写菜谱就有两个绕不开的问题:第一张菜谱做出的半成品,怎么给第二张菜谱接着做?做菜时要用的盐和火候这类全局调料,又怎么从你(厨房外的采购)手里送进去?没有这两条「递料」的通道,两段程序就是两座孤岛,画面要么算不出、要么永远是死板的一个样。这一章讲的就是这两条通道。

Shader 是阶段间数据契约

attributes/uniforms · interface · interpolation · output

1. Vertex inputsattributes + uniforms

每顶点数据和全局参数

2. Stage interfaceout ↔ in

link-time 类型匹配

3. Interpolationvertex values → fragments

逐片段 varying

4. Fragment outputout vec4 → attachment

颜色进入 framebuffer

Attribute 随 invocation 变化,uniform 对一次 draw 保持一致,out/in 经 link 建立接口,光栅化再插值成逐片段输入。

GLSL:写菜谱用的小语言

这两张菜谱用的不是 C++ 也不是 JavaScript,而是一门专门的小语言 (OpenGL Shading Language)。它长得很像 C:有 floatiffor、函数、main()

为什么要单独一门语言?因为 GPU 的活法和 CPU 不同——同一段代码会被几千个核心同时拿去,各自处理一个顶点或一个像素。GLSL 就是为这种「一份代码、海量并行」量身定做的,还内置了图形最常用的向量、矩阵类型。本章不求把语法学全,只抓住对小白最关键的三件事:数据怎么进来、怎么在两段之间传、怎么输出

in 和 out:把半成品从上一段递给下一段

第一张菜谱(顶点着色器)做出的半成品,要交给第二张(片段着色器)接着加工。GLSL 用一对关键字管这件事:上一段用 中的 out 声明「我要往下递一份数据」,下一段用同名同类型的 in 声明「我来接这份数据」。

这里有条铁律:上一段 out 的名字和类型,必须和下一段 in 的名字和类型一字不差,否则两根管子对不上、接不起来。比如顶点着色器写 out vec3 vColor;,片段着色器就得写 in vec3 vColor;——名字 vColor、类型 vec3 都得一致。

下面这张图把本章两条数据通路画在一起:一条是 in/out(半成品在两段之间流),另一条就是下一节要讲的 uniform(从你的程序旁路注入)。先看个全貌:

uniform:从你的程序送进去的「全局调料」

in/out 传的是「逐顶点 / 逐片段各不相同」的数据。但还有一类数据,是对这一整次绘制都一样的——比如当前时间、整体色调、一个变换矩阵。这类「全局调料」由 负责。

它的特点全在名字里——uniform(统一的):在一次绘制中,每个顶点、每个片段读到的这个值都完全相同。你在程序里用一句 gl.uniform* 把它上传,着色器里就能读到(但只能读、不能改)。in/out 是顺着流水线一段段往下流,uniform 则是从流水线侧面横插进来,两段着色器都够得着。上面那张图里,从 CPU 虚线垂下来的就是它。

本章的主 Demo 里,「随时间脉动」靠的就是一个名叫 uTime 的 uniform,你在控件里调的颜色也是一个 uniform——稍后你会亲手调它们。

基本类型:float 和「装一组数的盒子」vec

GLSL 的类型也是为图形定制的。除了熟悉的 float(小数)、intbool,最常用的是 vec2 / vec3 / vec4——分别是装 2、3、4 个 float 的小盒子:

  • 颜色用 vec4vec4(r, g, b, a),四个分量是红、绿、蓝、透明度(都在 0~1)
  • 平面坐标用 vec2,空间坐标 / RGB 颜色用 vec3
  • 矩阵用 mat3 / mat4(3×3、4×4),做坐标变换用——本章先不展开,留给后面「变换」章

向量取分量很灵活,这套写法叫 color.r 取红分量,color.rgb 一次取前三个,pos.xy 取前两个。颜色用 .rgba、坐标用 .xyzw,指的是同一批分量,只是叫法不同。整组向量还能直接相加、相乘、和 float 混算,写起来很省事——这也是 GLSL 比普通语言「图形味」浓的地方。

GLSL 类型决定接口和上传函数

scalar · vector · matrix · opaque resource types

1. Scalarbool · int · uint · float

明确数值域

2. Vectorvec · ivec · uvec · bvec

分量运算与 swizzle

3. Matrixmat2 · mat3 · mat4

列主序线性变换

4. Opaquesampler · image

资源句柄不可随意复制

CPU 上传函数必须与 active uniform 类型和分量数匹配;向量 swizzle 可重排分量,但不能越过源向量维度。

整数向量用 ivec/uvec,布尔向量用 bvec;matrix 与 vector 乘法有线性代数语义,不是逐分量乘。Sampler、image 等 opaque types 表示资源访问入口,不能像普通 struct 一样任意复制或作为所有表达式的操作数。CPU 侧 uniform1f/3f/Matrix4fv 必须与 active uniform 的类型、分量数和 array length 对应。

顶点只有 3 个角,中间的颜色哪来的?——插值

这是小白最容易懵的一点。顶点着色器只对那几个顶点跑(三角形就 3 个角),可片段着色器要给三角形铺满的每一个像素算色——中间那些片段的 in 值是哪来的?

答案是 :光栅化把三角形切成一个个片段时,会顺手把三个顶点的 out 值,按这个片段离三个角的远近,自动混合算出来。靠近红色角的片段偏红,正中间的片段是三色匀混——于是你看到一片连续的渐变,而你只在顶点上定了三个角的值。

记住这个直觉:顶点着色器定「角上的值」,中间的值是光栅化插值「混」出来的,到了片段着色器就是现成的 in。这正是本章主 Demo 里那片渐变的来历。

默认 varying 会做透视正确插值;若数据必须保持 provoking vertex 的离散值(例如 material ID),两端都要使用 flat out / flat in。整数 varying 必须使用 flat,因为“材质编号 2.4”没有合法语义。Stage interface 是否匹配在 program link 时检查,而不是等到 draw 才猜。

动手:调 uniform、改 GLSL

下面就是本章主 Demo。片段着色器做了两件本章讲过的事:用 gl_FragCoord(片段的屏幕位置)做位置渐变,再用 uTime 这个 uniform 让颜色随时间脉动。右侧两个控件——基色和脉动速度——各自驱动一个 uniform,拖一下就实时变。

猜一猜:把右边的「脉动速度」调到 0,画面会怎样?再把它调大,又会怎样?这背后变的是哪个 uniform?动手拖一下再看下面的代码。

可交互

实时演示加载中…

调速度变的是 uSpeed 这个 uniform:调到 0,sin 里的 uTime * uSpeed 恒为 0、pulse 不再随时间变,画面就静止;调大则脉动更快。改基色变的是 uColor。两个控件都没有重新编译着色器——只是每帧把新值通过 gl.uniform* 上传进去,这正是 uniform 的用法。

代码逐段拆解

把上面 Demo 背后的写法摊开看。一对着色器 + 从你的程序往里传 uniform,关键就这几段。C++/OpenGL 与 WebGL2/TS 互为镜像。

第一步:一对会「传颜色」的着色器

我们让顶点着色器给每个顶点定一个颜色,用 out 递出去;片段着色器用同名 in 接收(中间已被插值),原样输出。注意 out vColorin vColor 的名字、类型必须对上。

// 顶点着色器
#version 330 core
layout (location = 0) in vec3 aPos;   // 顶点属性:位置(来自 VBO)
out vec3 vColor;                      // out:往片段着色器递的颜色
void main() {
  gl_Position = vec4(aPos, 1.0);      // 内建输出:顶点的裁剪空间位置
  vColor = aPos * 0.5 + 0.5;          // 用位置算个颜色,递出去
}

gl_Position 和下面片段着色器的 FragColor 都不是我们声明的变量,而是 GLSL 规定好的 :顶点着色器必须gl_Position 赋值(顶点的最终位置),片段着色器则把一个 out vec4 当最终颜色输出。接着看片段着色器:

// 片段着色器
#version 330 core
in vec3 vColor;            // in:接住顶点着色器递来的颜色(已被插值)
out vec4 FragColor;        // out:这个片段的最终颜色
void main() {
  FragColor = vec4(vColor, 1.0);
}

两端逻辑完全相同,差异在版本声明和那行精度声明:

第二步:在着色器里声明要接的 uniform

要从程序里传 uTime、颜色这类全局调料,先在着色器里声明同名 uniform。声明在两段着色器里都可以写——哪段要用就在哪段声明:

// 片段着色器顶部加:
uniform float uTime;       // 全局:运行时间(秒)
uniform vec3  uColor;      // 全局:基色
// main 里就能像普通变量一样读它们(只读):
// FragColor = vec4(uColor * abs(sin(uTime)), 1.0);

着色器里的声明两端完全一样(都是 GLSL)。真正有 API 差异的是「从你的程序怎么把值送进去」——这才是下一步的重点。

第三步:从 CPU 把值上传给 uniform

这里的「程序」指上一章链接好的那个 。上传分两步:先用 getUniformLocation 按名字问出这个 uniform 在程序里的「地址」(一个句柄),再用 glUniform* / gl.uniform* 把值写进那个地址。类型要对上——float1fvec33f

glUseProgram(program);  // 上传前必须先启用目标程序!
// 按名字问出 uniform 的位置(句柄)
int locTime  = glGetUniformLocation(program, "uTime");
int locColor = glGetUniformLocation(program, "uColor");
// 写值:float 用 1f,vec3 用 3f
glUniform1f(locTime, currentTime);
glUniform3f(locColor, 0.49f, 0.36f, 1.0f);

逻辑一一对应,只有两个命名 / 返回值差异需要留意:

第四步:动画——每帧重新上传 uTime

uniform 只是「写进去的那一刻」的值;要让画面动,得在渲染循环里每帧把新的 uTime 写一遍。这就是上面 Demo「随时间脉动」的全部秘密:

while (!glfwWindowShouldClose(window)) {
  glUseProgram(program);
  float t = (float)glfwGetTime();         // 取当前秒数
  glUniform1f(locTime, t);                // 每帧更新 uTime
  glBindVertexArray(VAO);
  glDrawArrays(GL_TRIANGLES, 0, 3);
}

每帧 uTime 变一点,片段着色器里 sin(uTime) 跟着变,颜色就动了起来。本站的 <ShaderDemo> 已经替你做好了 uTime / uResolution / uMouse 的每帧上传,控件里的自定义 uniform(如 uColor / uSpeed)也是这套机制——你只管在 GLSL 里读。完整可运行源码将在章末提供(M5)。

Shader Class:封装生命周期,不能吞掉日志

官方章节最后把重复流程收进。目标不是让错误消失,而是让每次失败都携带 stage、source path 与完整 info log。

Shader Class 封装的是生命周期,不是隐藏错误

read · compile · link · cache/use/destroy

1. Read filesvertexPath + fragmentPath

完整 source 与路径错误

2. Compile stagesstatus + info log

逐阶段诊断

3. Link programinterface + link log

可执行 program

4. Use + destroycache locations · delete

稳定更新与资源释放

封装必须保留 shader/program logs 与 source path;成功 link 后删除临时 shader objects,program 由 RAII 或显式 destroy 释放。
class Shader {
public:
    GLuint ID = 0;
    Shader(const char* vertexPath, const char* fragmentPath) {
        std::string vs = readText(vertexPath);
        std::string fs = readText(fragmentPath);
        GLuint v = compile(GL_VERTEX_SHADER, vs, vertexPath);
        GLuint f = compile(GL_FRAGMENT_SHADER, fs, fragmentPath);
        ID = glCreateProgram();
        glAttachShader(ID, v);
        glAttachShader(ID, f);
        glLinkProgram(ID);
        checkProgramLog(ID);
        glDeleteShader(v);
        glDeleteShader(f);
    }
    ~Shader() { if (ID) glDeleteProgram(ID); }
    void use() const { glUseProgram(ID); }
    void setVec3(GLint location, const glm::vec3& v) const {
        glUniform3fv(location, 1, &v.x);
    }
};

文件 I/O failure、shader compile status 和 program link status 是三类独立错误。成功 link 后 program 已持有 executable,临时 shader objects 可删除;program 本身应由 RAII destructor 或明确 destroy() 释放。Uniform location 在 link 后稳定,可初始化时查询并缓存;program relink 后旧 location 失效,不能跨 relink 复用。

三步验证 Shader 数据契约

分步1 / 3

第一步:先闭合输入与阶段接口

核对 attribute location/type,逐字匹配 vertex out 与 fragment in;保存 compile 和 link status/log,不把黑屏当诊断结果。

Shader 是阶段间数据契约

attributes/uniforms · interface · interpolation · output

1. Vertex inputsattributes + uniforms

每顶点数据和全局参数

2. Stage interfaceout ↔ in

link-time 类型匹配

3. Interpolationvertex values → fragments

逐片段 varying

4. Fragment outputout vec4 → attachment

颜色进入 framebuffer

Attribute 随 invocation 变化,uniform 对一次 draw 保持一致,out/in 经 link 建立接口,光栅化再插值成逐片段输入。

容易踩的坑

小结

  • GLSL 是写着色器的小语言,一份代码被 GPU 对海量顶点 / 片段并行跑
  • in/out 在两段着色器之间传逐顶点 / 逐片段的数据,名字和类型必须配对
  • uniform 是从你的程序传进去的全局常量,对一次绘制里所有顶点 / 片段同值,只读
  • 片段着色器那片连续渐变,是顶点的 out 值经插值「混」出来的(顶点只定角上的值)
  • 改 uniform / 改 GLSL 就能改出不同颜色与动画;动画 = 每帧重新上传 uTime

练习

问题 1: 在上面的可编辑 Demo 里,做出「纯色呼吸灯」效果:整块画面是同一种颜色,亮度随时间一明一暗地呼吸(不要位置渐变)。提示:去掉 uv 的影响,让颜色只由 uColorpulse 决定。

问题 2: 顶点着色器里写 out vec3 vColor;,片段着色器里写 in vec4 vColor;,会发生什么?为什么?

问题 3: 不看上面的代码,自己写出「从程序里把一个 vec3 颜色 uniform 传进片段着色器」的完整三步(着色器里声明、问位置、写值),目标效果:让三角形显示成你指定的纯色。

名词解释

本章出现的专业名词,用大白话再讲一遍。

GLSL

写着色器用的一门小语言(OpenGL Shading Language),语法很像 C,但专为「同一段代码在 GPU 上对成千上万个顶点 / 像素同时跑」设计,还自带向量、矩阵这些图形常用类型。详见本章「GLSL」一节。

in / out

GLSL 里声明「输入 / 输出」的关键字。一段着色器用 out 声明要往下一段递的数据,下一段用同名同类型in 接收——是两段着色器之间传数据的管道。名字或类型对不上就接不起来。详见本章「in 和 out」一节。

uniform

从你的程序(CPU)传进着色器的全局只读变量。一次绘制里,所有顶点和片段读到的它都是同一个值;uniform 即“统一的”,适合放时间、整体颜色、变换矩阵等。着色器里只能读不能改。详见本章「uniform」一节。

向量类型

GLSL 装一组 float 的小盒子:vec2 / vec3 / vec4 分别装 2 / 3 / 4 个数。常用来表示位置 (x,y,z)、颜色 (r,g,b,a)、纹理坐标等,还能整组直接加减乘。详见本章「基本类型」一节。

swizzle

取向量分量的简写:用 .x/.y/.z/.w.r/.g/.b/.a,还能一次取多个并重排——color.rgb 取前三个、pos.xy 取前两个、c.bgr 反序。坐标习惯用 xyzw、颜色用 rgba,指的是同一批分量。

片段插值

顶点着色器只对几个顶点跑,可三角形内部铺满了片段。光栅化时,内部每个片段的 in 值由三个顶点的 out按它离三个角的远近自动混合算出——所以顶点只定了角上的值,中间是「混」出来的连续渐变。详见本章「插值」一节。

内建变量

GLSL 预先定义好、有固定含义的特殊变量。比如顶点着色器必须gl_Position(顶点最终位置)赋值,片段着色器可读 gl_FragCoord(当前片段的屏幕坐标,只读),并把一个 out vec4 当最终颜色输出。详见本章「第一步」。

精度限定符(precision)

GLSL ES(WebGL2)里给浮点数指定计算精度的声明,如 precision highp float;(高精度)。WebGL2 的片段着色器必须显式声明它,否则编译失败;桌面 OpenGL 不需要这行。详见本章「容易踩的坑」。

着色器程序

把编译好的顶点着色器和片段着色器链接到一起、能被显卡执行的整体。上传 uniform、绘制前都要先用 useProgram 启用它,显卡才知道该用哪两段着色器、把值写进哪个程序。

着色器封装

从文件读取 GLSL、逐阶段编译、链接 program、保留诊断日志、设置 uniform 并负责删除 program 的生命周期封装。

版本、来源与运行边界

本章以 Joey de Vries 的 LearnOpenGL 原章 为授权改编依据,教学运行基线是 OpenGL 3.3 Core Profile。Khronos 当前发布的规范参照是 OpenGL 4.6 Core Profile;这里用 4.6 规范核查术语和状态合同,但不把 4.6 API 偷偷倒填为原教程内容。GLFW、GLAD、Assimp 与驱动版本都属于运行环境,不能拿“编译通过”替代对 context、资源和 framebuffer 结果的验证。

正式概念与状态责任

  • glsl:在“GLSL 数据接口、Uniform 与 Shader Class”中由已链接 shader program 与 CPU 侧 Shader 封装负责解释其输入、受控状态和可观察结果;运行时以逐阶段编译日志、program 链接日志、active uniform/interface 查询与输出像素定位它的第一处变化。
  • vertex shader:在“GLSL 数据接口、Uniform 与 Shader Class”中由已链接 shader program 与 CPU 侧 Shader 封装负责解释其输入、受控状态和可观察结果;运行时以逐阶段编译日志、program 链接日志、active uniform/interface 查询与输出像素定位它的第一处变化。
  • fragment shader:在“GLSL 数据接口、Uniform 与 Shader Class”中由已链接 shader program 与 CPU 侧 Shader 封装负责解释其输入、受控状态和可观察结果;运行时以逐阶段编译日志、program 链接日志、active uniform/interface 查询与输出像素定位它的第一处变化。
  • uniform:在“GLSL 数据接口、Uniform 与 Shader Class”中由已链接 shader program 与 CPU 侧 Shader 封装负责解释其输入、受控状态和可观察结果;运行时以逐阶段编译日志、program 链接日志、active uniform/interface 查询与输出像素定位它的第一处变化。

章专属 OpenGL 状态实验

先预测“编译 vertex/fragment shader,链接 program,再对当前 program 上传 uniform”发生后,已链接 shader program 与 CPU 侧 Shader 封装应怎样改变源码、编译对象、阶段接口、链接结果、uniform location 和当前 uniform 值;再操作三个实验。实验不生成变化率或正确率等虚构总分,只显示真实 GL 状态、资源、命令和可观察结果。

实验一:Context—资源—结果合同

选择任一正式概念与基线/单故障场景,核对它是否进入本章状态合同。正式概念只有同时出现在解释、可视状态和交付证据中才算覆盖。

Context · resource · observable result

GLSL 数据接口、Uniform 与 Shader Class:状态合同

建立 GLSL 阶段接口、uniform 更新和 Shader 类资源寿命的完整合同

验证场景

官方教程正式概念

logl-04 · 基线帧

glsl固定 context、资源内容与输入事件,执行“编译 vertex/fragment shader,链接 program,再对当前 program 上传 uniform”

状态所有者已链接 shader program 与 CPU 侧 Shader 封装
受控状态/资源源码、编译对象、阶段接口、链接结果、uniform location 和当前 uniform 值
触发命令编译 vertex/fragment shader,链接 program,再对当前 program 上传 uniform

冻结输入:glsl

已链接 shader program 与 CPU 侧 Shader 封装记录源码、编译对象、阶段接口、链接结果、uniform location 和当前 uniform 值

结果:得到可重复的初始 GL 状态与资源身份

观测:逐阶段编译日志、program 链接日志、active uniform/interface 查询与输出像素中的初始快照

预期:已链接 shader program 与 CPU 侧 Shader 封装得到可复查结果,并持续满足“相邻阶段的 in/out 名称以 location/type 匹配,uniform 写入目标 program”

实验二:CPU 命令到 GPU 结果的五段轨迹

逐段执行“编译 vertex/fragment shader,链接 program,再对当前 program 上传 uniform”,在每一步记录资源身份、状态变化与第一个可观察结果,并持续核对“相邻阶段的 in/out 名称以 location/type 匹配,uniform 写入目标 program”。

CPU command · GL state · GPU result

GLSL 数据接口、Uniform 与 Shader Class:五段轨迹

选择一段命令—资源—结果1 / 5

当前观测:逐阶段编译日志、program 链接日志、active uniform/interface 查询与输出像素中的初始快照

不变量:相邻阶段的 in/out 名称以 location/type 匹配,uniform 写入目标 program

实验三:单故障与同输入恢复

注入“顶点输出 vec3,片段输入声明为 vec2,链接失败却忽略日志继续绘制”,保存首个分岔;撤销后沿用完全相同的 context、资源内容、uniform 和 draw 输入重放。只有逐阶段编译日志、program 链接日志、active uniform/interface 查询与输出像素一起恢复才算修复。

Single fault · first divergence · replay

GLSL 数据接口、Uniform 与 Shader Class:反例与恢复

故障:顶点输出 vec3,片段输入声明为 vec2,链接失败却忽略日志继续绘制

1. 冻结输入一致

第 1 次沿用同一 context、资源、uniform 与 draw 输入

2. 注入单故障一致

保持其余输入不变,仅注入“顶点输出 vec3,片段输入声明为 vec2,链接失败却忽略日志继续绘制”

3. 定位首差一致

相邻阶段的 in/out 名称以 location/type 匹配,uniform 写入目标 program

4. 清理并重放一致

逐阶段编译日志、program 链接日志、active uniform/interface 查询与输出像素

最小可重放检查

unit: logl-04
owner: 已链接 shader program 与 CPU 侧 Shader 封装
state_or_resource: 源码、编译对象、阶段接口、链接结果、uniform location 和当前 uniform 值
command: 编译 vertex/fragment shader,链接 program,再对当前 program 上传 uniform
pass_invariant: 相邻阶段的 in/out 名称以 location/type 匹配,uniform 写入目标 program
single_fault: 顶点输出 vec3,片段输入声明为 vec2,链接失败却忽略日志继续绘制
required_evidence: 逐阶段编译日志、program 链接日志、active uniform/interface 查询与输出像素

复核者先仅依据以上合同写出预期,再运行基线、单故障和清理后重放。若两次基线的资源身份、首个状态变化或 framebuffer 结果不同,必须保留差异,不能用最终截图相似掩盖中间状态错误。

出处声明

本文为改编重写,改编自 Joey de Vries 的 LearnOpenGL。原文:learnopengl.com

原作及译作以 CC BY-NC 4.0 协议授权,本改编版同样遵循该协议(署名—非商业性使用)。

讨论

评论区加载中…