理解着色器(Shader)如何操作 XYZ(空间位置) 和 UV(纹理坐标) ,最核心的窍门是:把显卡(GPU)想象成一个"超级并行流水线加工厂"。
在这个工厂里,顶点着色器(Vertex Shader)和片段着色器(Fragment Shader)各司其职,它们处理 XYZ 和 UV 的方式就像是一场几何变换与颜色填充的接力赛。
1. 概念拆解:它们在内存里到底是什么?
在你定义的 vertices[] 数组中,所谓的 XYZ 和 UV 只是显存里一连串紧密排列的数字:
// 一行就是一个顶点的"复合档案"
-0.5f, -0.5f, -0.5f, 0.0f, 0.0f // 前三个是 XYZ,后两个是 UV
通过 glVertexAttribPointer,你明确地告诉了 GPU:
-
Location 0 (XYZ) :这是顶点的骨骼。
-
Location 1 (UV) :这是顶点的皮肤锚点(U和V的取值范围永远是 0.0 到 1.0)。
2. 顶点着色器:如何操控 XYZ 和 UV?
顶点着色器是逐顶点执行的。你的立方体有 36 个顶点,GPU 会启动 36 个微型线程,同时并行处理这 36 个点。
核心任务:把 XYZ 从"本地"推向"屏幕"
在顶点着色器中,最关键的一行操作是:
OpenGL Shading Language
gl_Position = projection * view * model * vec4(aPos, 1.0);
这里操作的就是 XYZ 。模型矩阵、观察矩阵、投影矩阵(MVP 魔法)连续作用于输入的 3D 坐标 aPos。
-
它的物理意义 :把物体从自己的小世界(Local Space),旋转平移到世界场景中(World Space),再根据相机位置调整(View Space),最后根据"近大远小"的透视原理压扁成一个 2D 剪裁空间坐标(NDC)。
-
操作结果:决定了最终这个顶点会出现在显示器屏幕的哪个像素位置。
边缘任务:对 UV 只是"盖个章,传下去"
在顶点着色器里,GPU 对 UV 没做任何数学运算:
OpenGL Shading Language
TexCoord = aTexCoord;
这就像是在衣服的某个裁片上盖了个"锚点戳记",告诉流水线:这个顶点在图片上死死对应着 (0.0, 0.0) 这个位置。
3. 硬件黑魔法:极为关键的"光栅化插值"
在顶点着色器结束、片段着色器开始之前,显卡硬件会执行一个被称为光栅化(Rasterization)的隐式步骤。这是理解 UV 操作最精妙的地方。
假设三角形的顶点 A 对应的 UV 是 (0.0, 0.0),顶点 B 对应的 UV 是 (1.0, 0.0)。
当这两个顶点在屏幕上画出一条线时,线段中间那千万个"像素点"的 UV 该是多少?
GPU 硬件会自动进行线性插值(Interpolation) 。线段正中央的那个像素,分到的 UV 恰好会自动变成 (0.5, 0.0)。
总结 :虽然你在顶点着色器里只传了 36 个顶点的 UV,但到了片段着色器时,GPU 已经自动帮你计算出了屏幕上每一个像素点精确对应的插值 UV。
4. 片段着色器:如何操控 UV 映射出颜色?
片段着色器(Fragment Shader)是逐像素执行的。
核心任务:拿着插值后的 UV 去"抠图"
在片段着色器中,操作变成了:
OpenGL Shading Language
FragColor = texture(ourTexture, TexCoord);
这时候 XYZ 已经退场了 (它们已经变成了屏幕上的像素格子),轮到 UV 大显身手:
-
TexCoord传入当前像素点的插值 UV(比如(0.5, 0.5),代表图片的中心)。 -
texture()函数像一把高精度的工兵铲,直接滑行到显存中那张14.jpg的正中央,把那个像素的 RGB 颜色"抠"出来。 -
最终赋值给
FragColor,这个像素格子的颜色就被填上了。
5. 进阶:我们在着色器里还能对它们玩出什么花样?
理解了上述原理,你就可以在 Shader 里通过修改 XYZ 和 UV 来实现各种疯狂的特效:
动态改变 XYZ(比如:顶点着色器里的波浪特效)
如果我们在顶点着色器里加入正弦函数:
OpenGL Shading Language
vec3 modifiedPos = aPos;
modifiedPos.y += sin(timeValue + aPos.x) * 0.1; // 根据 X 坐标和时间,让 Y 轴上下晃动
gl_Position = projection * view * model * vec4(modifiedPos, 1.0);
- 效果:你的硬核立方体顿时会像水波或果冻一样扭曲蠕动起来。
动态改变 UV(比如:片段着色器里的贴图滚动 / 滤镜)
如果我们在片段着色器里对 UV 进行数学运算:
OpenGL Shading Language
// 让 U 轴(水平方向)随着时间不断偏移
vec2 scrolledUV = vec2(TexCoord.x + timeValue * 0.1, TexCoord.y);
FragColor = texture(ourTexture, scrolledUV);
- 效果 :立方体表面上的图片会像传送带一样无限循环滚动 起来(因为你设置了
GL_REPEAT环绕模式)。
简而言之
-
XYZ 负责"骨架在哪":顶点着色器通过矩阵乘法,把 3D 的 XYZ 映射到 2D 屏幕。
-
UV 负责"皮肤怎么缝":光栅化阶段自动把顶点的 UV 织成一张覆盖整个面的"UV 坐标网";片段着色器拿着这张网上的每一个坐标去图片上抓取颜色。
1. 顶点着色器 (Vertex Shader) ------ 空间魔术师
再来看这三行最核心的代码:
OpenGL Shading Language
layout (location = 0) in vec3 aPos; // 接收 3D 坐标 (XYZ)
layout (location = 1) in vec2 aTexCoord; // 接收纹理坐标 (UV)
out vec2 TexCoord; // 准备传给下一个舞台的变量
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
void main() {
gl_Position = projection * view * model * vec4(aPos, 1.0);
TexCoord = aTexCoord;
}
⚙️ GPU 是如何对 XYZ 执行这行代码的?
-
数据拉取 :GPU 启动了一个线程。因为配置了
glVertexAttribPointer(0, 3, ...),它从显存的vertices数组里精准抽出了前三个 float(比如-0.5, -0.5, -0.5),塞进了aPos这个 3 维向量里。 -
维度齐次化 :
vec4(aPos, 1.0)把 3D 坐标强制变成了 4D 齐次坐标。为什么要加个1.0?因为只有 4D 向量才能和 4 \\times 4 的矩阵进行点乘,从而同时实现缩放、旋转和位移。 -
矩阵连乘(从右往左看):
-
model * ...:让这个小顶点跟着立方体一起旋转起来(对应 C++ 里的glm::rotate)。 -
view * ...:把你(相机)往后拉 3 个单位,站在相机的视角重新计算这个点的相对 XYZ 坐标。 -
projection * ...:最神奇的一步。它给坐标的 X 和 Y 除以一个与 Z(深度)正相关的比例。Z 越大(离镜头越远),算出来的 X 和 Y 就会被压缩得越小。这就是"近大远小"透视效果的数学本质。
-
-
交工 :算完的结果赋值给内建变量
gl_Position。GPU 看到这个变量,就知道这个点在屏幕的哪个像素像素点上了。
⚙️ GPU 是如何对 UV 执行这行代码的?
-
TexCoord = aTexCoord; -
本质 :在这个阶段,GPU 完全不操作 UV 。它只是把从 Location 1 读到的两个 float(比如
0.0, 0.0),盖上一个out的戳,扔进了硬件流水线的下一个传送带上。
2. 硬件隐式超能力 ------ 光栅化与线性插值
在顶点着色器(36个点)结束之后,片段着色器(数万个像素)开始之前,硬件会做一件极其牛的事:插值。
假设三角形的顶点 A 运行了顶点着色器,传出 TexCoord = vec2(0.0, 0.0);
顶点 B 运行了顶点着色器,传出 TexCoord = vec2(1.0, 0.0)。
当 GPU 要在屏幕上把 A 和 B 连成线并填充成面时,对于线段正中央的那个像素片元(Fragment),GPU 的硬件插值器会自动根据距离进行加权平均计算:
(0.0 + 1.0) / 2 = 0.5
于是,这个中央像素收到的 in vec2 TexCoord 就变成了 (0.5, 0.0)。每一张皮,都是这样被均匀"拉伸"开来的。
3. 片段着色器 (Fragment Shader) ------ 像素粉刷匠
现在,屏幕上成千上万个像素格都在排队等待上色。每个像素格子都会触发一次下面这段代码:
OpenGL Shading Language
out vec4 FragColor;
in vec2 TexCoord; // 接收到的是硬件自动插值过后的、属于当前像素的专属 UV
uniform sampler2D ourTexture; // 显存里的那张 14.jpg 贴图
void main() {
FragColor = texture(ourTexture, TexCoord);
}
⚙️ GPU 是如何对 UV 执行这行代码的?
-
精准定位 :假设当前像素是立方体正中心的一个点,它手里的
TexCoord被插值成了(0.5, 0.5)。 -
内存寻址(抠图) :
texture(ourTexture, TexCoord)函数开始执行。ourTexture指向你在 C++ 里通过glTexImage2D狠狠塞进显存的那张img.cols * img.rows分辨率的图片。 -
坐标换算:GPU 会把 0.0 \\sim 1.0 的相对坐标,换算成真实的图片像素行列。
-
图片物理像素 X = img.cols \\times 0.5
-
图片物理像素 Y = img.rows \\times 0.5
-
-
过滤采样(Filtering) :你在 C++ 里写了
GL_LINEAR(线性过滤)。如果换算出来的行列数不是整数(比如第400.3个像素),GPU 会自动把图片上(400, 400)、(401, 400)等周围 4 个物理像素的颜色拿出来做一次双线性插值,让贴图边缘看起来平滑没有锯齿。 -
输出颜色 :抠出来的
vec4(RGB 加上透明度 A,这里默认为 1.0)直接赋值给FragColor。
⚙️ 此时 XYZ 在哪里?
-
完全隐退 :在片段着色器中,你已经看不到任何矩阵,也看不到 X, Y, Z 的字眼了。因为 X 和 Y 已经变成了当前执行线程在显示器上的像素阵列坐标 ;而 Z 已经被送进了
GL_DEPTH_TEST(深度测试)总闸口。 -
如果这个像素的 Z 值比之前画的物体更远,GPU 甚至会直接无视并杀掉这个片段着色器线程(Early-Z技术),连图都不用抠了,以此来省下极大的算力。
在 OpenGL 的世界里,着色器(Shader)是运行在 GPU 上的独立程序。要让 CPU 中的数据(如你的顶点数组、OpenCV 图像、变换矩阵)传递到 GPU 供着色器使用,OpenGL 提供了 三种最核心的参数传递机制。
它们分工明确,分别应对不同类型、不同频率的数据传输。
1. Attributes(顶点属性输入):大批量、逐顶点的数据
-
关键字 :
in(在顶点着色器中,常结合layout (location = n)) -
传递频率 :极高。每个顶点都会读取一次。
-
适用场景:顶点的 3D 坐标(XYZ)、纹理坐标(UV)、法向量(Normal)、顶点颜色等。
在你的 C++ 代码中,数据是通过 VBO(顶点缓冲区对象) 塞进显存,然后用 glVertexAttribPointer 规定解析规则的:
// C++ 端:绑定数据并规定解析通道
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0); // 激活 Location 0
当程序运行时,顶点着色器会通过 layout (location = 0) 自动、并行地 将这些数据源源不断地吸收到 in 变量中:
OpenGL Shading Language
// Vertex Shader 端
layout (location = 0) in vec3 aPos; // 对应 C++ 的 Location 0
layout (location = 1) in vec2 aTexCoord; // 对应 C++ 的 Location 1
2. Uniforms(统一变量):全局、高频变动的常量
-
关键字 :
uniform -
传递频率 :中等 。通常每画一个物体(每一帧或每个批次)在 CPU 端更新一次,但该物体在 GPU 渲染数万个顶点/像素时,这个值保持完全相同(Uniform)。
-
适用场景 :MVP 变换矩阵(
model/view/projection)、时间步长(timeValue)、光照位置、纹理槽位(sampler2D)等。
传递流程:
-
Shader 内部声明 :在着色器中用
uniform定义变量。 -
CPU 端获取地址:在 C++ 中通过变量字符串名称,询问 GPU 该变量在显存里的"门牌号"(Location)。
-
CPU 端灌入数据 :使用
glUniformXxx系列函数将数据送入该门牌号。
你的代码中完美展现了这个过程:
OpenGL Shading Language
// Shader 端(顶点或片段着色器皆可声明)
uniform mat4 model;
// C++ 端
int modelLoc = glGetUniformLocation(shaderProgram, "model"); // 1. 找门牌号
glUniformMatrix4fv(modelLoc, 1, GL_FALSE, glm::value_ptr(model)); // 2. 灌入矩阵数据
特别注意(纹理的特殊传递) :对于
uniform sampler2D ourTexture;,你往里灌的不是复杂的图片像素数组,而是一个数字(纹理槽位编号,如 0) 。你只需要告诉 GPU:"去 0 号纹理单元(GL_TEXTURE0)里拿图",真正的图片数据是通过glTexImage2D提前绑定到该槽位上的。
3. Varyings(管线内部传递):从 Vertex 到 Fragment 的接力棒
-
关键字 :
out(顶点着色器传出)/in(片段着色器传入) -
传递频率 :无(不经过 CPU) 。这是 GPU 内部流水线级的数据自传递。
-
适用场景:把顶点着色器阶段算好的/读到的数据(如变换后的法线、插值后的 UV 坐标、顶点在世界空间的位置),传递给片段着色器。
传递流程与黑魔法(光栅化插值):
-
顶点着色器中定义一个
out变量,并给它赋值。 -
片段着色器中定义一个同名、同类型 的
in变量。 -
核心机密 :数据从
out到in之间,会经过显卡硬件的光栅化插值器 。片段着色器拿到的不是顶点传出的原值,而是根据当前像素位置线性插值平滑过渡后的值。
你的代码中表现如下:
OpenGL Shading Language
// ======= 1. 顶点着色器 =======
out vec2 TexCoord; // 定义传出戳
void main() {
TexCoord = aTexCoord; // 赋值
}
// ======= 2. 片段着色器 =======
in vec2 TexCoord; // 定义同名传入戳(此时数据已被硬件自动插值!)
void main() {
FragColor = texture(ourTexture, TexCoord); // 直接使用
}
总结与选择指南
你可以通过下表快速记忆这三种传参方式的区别:
| 参数类型 | 关键字 | 数据源头 → 终点 | 更新频率 | 典型数据内容 |
|---|---|---|---|---|
| Attributes | in (layout) |
CPU 数组 \\rightarrow 顶点着色器 | 极高(逐个顶点都不同) | 顶点坐标 XYZ、纹理坐标 UV |
| Uniforms | uniform |
CPU 变量 \\rightarrow 所有着色器 | 中低(每帧/每个物体更一次) | MVP 矩阵、材质颜色、纹理槽位 |
| Varyings | out / in |
顶点着色器 \\rightarrow 片段着色器 | GPU 内部(伴随硬件自动插值) |