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
每顶点数据和全局参数
link-time 类型匹配
逐片段 varying
颜色进入 framebuffer
GLSL:写菜谱用的小语言
这两张菜谱用的不是 C++ 也不是 JavaScript,而是一门专门的小语言 ↡OpenGL Shading Language,写着色器用的小语言,语法像 C;专为「同一段代码在 GPU 上对成千上万个顶点/像素并行跑」而设计,自带 vec/mat 等图形类型。(OpenGL Shading Language)。它长得很像 C:有 float、if、for、函数、main()。
为什么要单独一门语言?因为 GPU 的活法和 CPU 不同——同一段代码会被几千个核心同时拿去,各自处理一个顶点或一个像素。GLSL 就是为这种「一份代码、海量并行」量身定做的,还内置了图形最常用的向量、矩阵类型。本章不求把语法学全,只抓住对小白最关键的三件事:数据怎么进来、怎么在两段之间传、怎么输出。
in 和 out:把半成品从上一段递给下一段
第一张菜谱(顶点着色器)做出的半成品,要交给第二张(片段着色器)接着加工。GLSL 用一对关键字管这件事:上一段用 ↡GLSL 里声明「输出 / 输入」的关键字。一段着色器用 out 声明要往下一段递的数据,下一段用同名同类型的 in 接收。它们是阶段之间传数据的管道。 中的 out 声明「我要往下递一份数据」,下一段用同名同类型的 in 声明「我来接这份数据」。
这里有条铁律:上一段 out 的名字和类型,必须和下一段 in 的名字和类型一字不差,否则两根管子对不上、接不起来。比如顶点着色器写 out vec3 vColor;,片段着色器就得写 in vec3 vColor;——名字 vColor、类型 vec3 都得一致。
下面这张图把本章两条数据通路画在一起:一条是 in/out(半成品在两段之间流),另一条就是下一节要讲的 uniform(从你的程序旁路注入)。先看个全貌:
uniform:从你的程序送进去的「全局调料」
in/out 传的是「逐顶点 / 逐片段各不相同」的数据。但还有一类数据,是对这一整次绘制都一样的——比如当前时间、整体色调、一个变换矩阵。这类「全局调料」由 ↡从 CPU(你的程序)传进着色器的全局只读变量。对一次绘制里所有顶点和片段都是同一个值;典型用途:时间、颜色、变换矩阵。在着色器里只能读不能改。 负责。
它的特点全在名字里——uniform(统一的):在一次绘制中,每个顶点、每个片段读到的这个值都完全相同。你在程序里用一句 gl.uniform* 把它上传,着色器里就能读到(但只能读、不能改)。in/out 是顺着流水线一段段往下流,uniform 则是从流水线侧面横插进来,两段着色器都够得着。上面那张图里,从 CPU 虚线垂下来的就是它。
本章的主 Demo 里,「随时间脉动」靠的就是一个名叫 uTime 的 uniform,你在控件里调的颜色也是一个 uniform——稍后你会亲手调它们。
基本类型:float 和「装一组数的盒子」vec
GLSL 的类型也是为图形定制的。除了熟悉的 float(小数)、int、bool,最常用的是 ↡GLSL 的向量类型:vec2/vec3/vec4 分别装 2/3/4 个 float。常用来表示位置(x,y,z)、颜色(r,g,b,a)、纹理坐标等。可整体参与加减乘运算。 vec2 / vec3 / vec4——分别是装 2、3、4 个 float 的小盒子:
- 颜色用
vec4:vec4(r, g, b, a),四个分量是红、绿、蓝、透明度(都在 0~1) - 平面坐标用
vec2,空间坐标 / RGB 颜色用vec3 - 矩阵用
mat3/mat4(3×3、4×4),做坐标变换用——本章先不展开,留给后面「变换」章
向量取分量很灵活,这套写法叫 ↡GLSL 取向量分量的简写:用 .x/.y/.z/.w 或 .r/.g/.b/.a,还能一次取多个并重排,如 color.rgb 取前三个、pos.xy 取前两个、c.bgr 反序。:color.r 取红分量,color.rgb 一次取前三个,pos.xy 取前两个。颜色用 .rgba、坐标用 .xyzw,指的是同一批分量,只是叫法不同。整组向量还能直接相加、相乘、和 float 混算,写起来很省事——这也是 GLSL 比普通语言「图形味」浓的地方。
GLSL 类型决定接口和上传函数
scalar · vector · matrix · opaque resource types
明确数值域
分量运算与 swizzle
列主序线性变换
资源句柄不可随意复制
整数向量用 ivec/uvec,布尔向量用 bvec;matrix 与 vector 乘法有线性代数语义,不是逐分量乘。Sampler、image 等 opaque types 表示资源访问入口,不能像普通 struct 一样任意复制或作为所有表达式的操作数。CPU 侧 uniform1f/3f/Matrix4fv 必须与 active uniform 的类型、分量数和 array length 对应。
顶点只有 3 个角,中间的颜色哪来的?——插值
这是小白最容易懵的一点。顶点着色器只对那几个顶点跑(三角形就 3 个角),可片段着色器要给三角形铺满的每一个像素算色——中间那些片段的 in 值是哪来的?
答案是 ↡光栅化时,三角形内部每个片段的 in 值,由三个顶点的对应 out 值按它到各顶点的远近自动混合算出。所以顶点只给了角上的值,中间是「混」出来的,得到连续过渡。:光栅化把三角形切成一个个片段时,会顺手把三个顶点的 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 vColor 与 in 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; // 用位置算个颜色,递出去
}// 顶点着色器(写成字符串)
const vertexSrc = `#version 300 es
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 规定好的 ↡GLSL 预先定义、有固定含义的特殊变量。如顶点着色器必须写 gl_Position(顶点最终位置)、片段着色器写 gl_FragCoord(片段屏幕坐标,只读)、自定义输出当颜色(如 FragColor)。:顶点着色器必须给 gl_Position 赋值(顶点的最终位置),片段着色器则把一个 out vec4 当最终颜色输出。接着看片段着色器:
// 片段着色器
#version 330 core
in vec3 vColor; // in:接住顶点着色器递来的颜色(已被插值)
out vec4 FragColor; // out:这个片段的最终颜色
void main() {
FragColor = vec4(vColor, 1.0);
}const fragmentSrc = `#version 300 es
precision highp float; // WebGL2 片段着色器必写:声明浮点精度
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);// 片段着色器字符串顶部加(注意 WebGL2 仍需 precision 那行):
// uniform float uTime; // 全局:运行时间(秒)
// uniform vec3 uColor; // 全局:基色
// main 里读法和 C++ 完全一样,GLSL 源码本身两端通用。着色器里的声明两端完全一样(都是 GLSL)。真正有 API 差异的是「从你的程序怎么把值送进去」——这才是下一步的重点。
第三步:从 CPU 把值上传给 uniform
这里的「程序」指上一章链接好的那个 ↡把编译好的顶点着色器和片段着色器链接到一起、能被显卡执行的整体。上传 uniform、绘制前都要先用 useProgram 启用它,显卡才知道该用哪两段着色器、把值写进哪个程序。。上传分两步:先用 getUniformLocation 按名字问出这个 uniform 在程序里的「地址」(一个句柄),再用 glUniform* / gl.uniform* 把值写进那个地址。类型要对上——float 用 1f、vec3 用 3f:
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);gl.useProgram(program); // 上传前必须先启用目标程序!
// 按名字问出 uniform 的位置(句柄;查不到返回 null)
const locTime = gl.getUniformLocation(program, "uTime");
const locColor = gl.getUniformLocation(program, "uColor");
// 写值:float 用 1f,vec3 用 3f
gl.uniform1f(locTime, currentTime);
gl.uniform3f(locColor, 0.49, 0.36, 1.0);逻辑一一对应,只有两个命名 / 返回值差异需要留意:
第四步:动画——每帧重新上传 uTime
uniform 只是「写进去的那一刻」的值;要让画面动,得在渲染循环里每帧把新的 uTime 写一遍。这就是上面 Demo「随时间脉动」的全部秘密:
while (!glfwWindowShouldClose(window)) {
glUseProgram(program);
float t = (float)glfwGetTime(); // 取当前秒数
glUniform1f(locTime, t); // 每帧更新 uTime
glBindVertexArray(VAO);
glDrawArrays(GL_TRIANGLES, 0, 3);
}function frame(tMs: number) {
gl.useProgram(program);
gl.uniform1f(locTime, tMs / 1000); // 每帧更新 uTime(毫秒转秒)
gl.bindVertexArray(vao);
gl.drawArrays(gl.TRIANGLES, 0, 3);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);每帧 uTime 变一点,片段着色器里 sin(uTime) 跟着变,颜色就动了起来。本站的 <ShaderDemo> 已经替你做好了 uTime / uResolution / uMouse 的每帧上传,控件里的自定义 uniform(如 uColor / uSpeed)也是这套机制——你只管在 GLSL 里读。完整可运行源码将在章末提供(M5)。
Shader Class:封装生命周期,不能吞掉日志
官方章节最后把重复流程收进↡负责从文件读取源码、编译各阶段、链接program、设置uniform并释放资源的着色器封装。目标不是让错误消失,而是让每次失败都携带 stage、source path 与完整 info log。
Shader Class 封装的是生命周期,不是隐藏错误
read · compile · link · cache/use/destroy
完整 source 与路径错误
逐阶段诊断
可执行 program
稳定更新与资源释放
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 数据契约
第一步:先闭合输入与阶段接口
核对 attribute location/type,逐字匹配 vertex out 与 fragment in;保存 compile 和 link status/log,不把黑屏当诊断结果。
Shader 是阶段间数据契约
attributes/uniforms · interface · interpolation · output
每顶点数据和全局参数
link-time 类型匹配
逐片段 varying
颜色进入 framebuffer
容易踩的坑
小结
- GLSL 是写着色器的小语言,一份代码被 GPU 对海量顶点 / 片段并行跑
- in/out 在两段着色器之间传逐顶点 / 逐片段的数据,名字和类型必须配对
- uniform 是从你的程序传进去的全局常量,对一次绘制里所有顶点 / 片段同值,只读
- 片段着色器那片连续渐变,是顶点的 out 值经插值「混」出来的(顶点只定角上的值)
- 改 uniform / 改 GLSL 就能改出不同颜色与动画;动画 = 每帧重新上传
uTime
练习
问题 1: 在上面的可编辑 Demo 里,做出「纯色呼吸灯」效果:整块画面是同一种颜色,亮度随时间一明一暗地呼吸(不要位置渐变)。提示:去掉 uv 的影响,让颜色只由 uColor 和 pulse 决定。
问题 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 的生命周期封装。