GLSL 数据接口、Uniform 与 Shader Class

掌握 GLSL 类型、阶段接口、插值与 uniform 上传,并把文件读取、编译链接、日志和释放封装为可复用 Shader class。

学习目标

  • 能解释 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 的生命周期封装。

讨论

评论区加载中…