想象你是一家玩具工厂的车间主任,手下有一条全自动流水线(GPU),这一批要生产彩色小球(绘制一帧画面)。流水线上有两道关键工序:整形工位把毛坯打磨成标准形状(顶点着色器),上色工位给表面喷漆(片元着色器)。要让产线转起来,你需要同时送进三类信息:贴满车间的“本批次生产要求”、每件产品独有的原料卡、工序之间传的流转单。这三样东西,正是 GPU 着色器编程里的 Uniform、Buffer 和 Varying 变量。搞懂它们从哪来、存在哪里、怎么变化,你就拿到了读懂任何 shader 的数据地图。
对 AI 从业者:图形学是 AI 可视化、数字孪生、具身智能仿真的底层能力,而 shader 变量是 GPU 数据流的骨架。理清三兄弟的来龙去脉,写 WebGL/OpenGL 的底气和 debug 能力都会上一个台阶。
目录
可视化演示
展示GPU渲染管线中Uniform、Buffer、Varying三类变量的数据流动路径:从CPU到顶点着色器、经插值传递到片元着色器,对比其作用域与存储方式。
本节配有交互动效(进入视口后自动加载,约 3KB)。
三类着色器变量的管线数据流 Uniform:全局常量,CPU→VS/FS Buffer:顶点数据,CPU→VS Varying = interpolate(VS_output) Varying由顶点着色器输出,经光栅化插值后传递给片元着色器。
变量总览——车间里的三条信息通道
车间里贴公告、发原料卡、传流转单,是三种性质完全不同的沟通方式。GPU 世界也一样:一台着色器程序要并行处理成千上万个顶点、上百万个片元,数据必须走专用通道,才能高效送到每个工位手上。如果只靠一条路传数据,流水线早就堵死了。
在 GLSL 里,这三种通道恰好对应三类变量。Uniform 是 CPU 写入 GPU 的只读全局常量,像公告栏一样所有工位共享同一份;Buffer(顶点缓冲对象 VBO)是驻留在显存中的顶点数据仓库,着色器通过 attribute 接口按顶点编号逐条取用,像原料架上每件产品各自一张原料卡;Varying 则是顶点着色器写给片元着色器的“半成品说明单”,途中还会被光栅化阶段自动插值加工。三者共同织成 GPU 内外的数据流动网络。
所以先记住一句话:Uniform 全局共享,attribute 顶点私有,varying 阶段传递。后面每一节,都在给这句话补充细节。
- CPU→Uniform→GPUCPU 把“本批次生产要求”这类全局只读数据提交给 GPU,整条渲染流水线共享同一份拷贝,着色器里不能改它。
- Buffer→attribute→VS显存中的顶点缓冲以 attribute 接口逐顶点喂给顶点着色器,每个顶点拿到自己那一行数据,不同顶点各不相同。
- VS→Varying→FS顶点着色器把计算结果写进 Varying,送往片元着色器;途中光栅化会自动对 Varying 做插值。
Uniform 常量——车间公告栏
车间主任每天开工前,会把本批次的生产要求写在公告栏上:产量 1000 个、表面颜色红色、硬度中等。所有工位抬头都能看到,但谁也不许涂改——这就是 Uniform 的本职。在 WebGL/OpenGL 里,CPU 使用 gl.uniform4fv、gl.uniformMatrix4fv 这类接口,把数据写进 GPU 的 uniform 存储区。着色器里所有并行线程读到的是同一份只读值。
实际项目里,Uniform 最常装的是这些“全场景通用”的小数据:模型视图投影矩阵、当前动画时间、光源方向与颜色、屏幕分辨率、雾浓度等等。它们的共同点是:量小、不随顶点变化、更新频率低(每帧甚至更久才动一次)。比如摄像机推近拉远,只需要每帧更新十几KB的矩阵数据,不值得惊动显存里的顶点大部队。
一句话回收比喻:公告栏上写什么,全车间就按什么干活;你只需要每帧改一次公告栏,不用对每个工人单独喊话。Uniform 就是这种“一次广播、全员执行”的高效通道。
- p 向量顶点在模型局部坐标里的原始位置,来自 Buffer 的 attribute 输入。
- M 模型矩阵把物体从模型空间放到世界空间,CPU 每帧写入 uniform。
- V 视图矩阵把世界坐标变换到相机视角,同样在 uniform 里。
- P 投影矩阵做透视或正交投影,产生近大远小的效果,由 CPU 计算后放入 uniform。
Buffer顶点缓冲——原料架与原料卡
光有公告栏还不够。1000 个球,每个球的半径、材质、花纹都不同,车间的“原料卡”必须一对一。GPU 里,这张原料卡存放在 Buffer / VBO 中:一块创建后常驻显存的数据区,里面紧密排列着顶点数组。每个顶点占一行,行内依次排好位置、颜色、UV、法线等字段,这些字段在着色器里就叫 attribute。
顶点着色器处理某个顶点时,GPU 会根据当前顶点编号,在缓冲里精准抓取那一行数据。CPU 要做三步准备工作:创建并填充 VBO;用 gl.vertexAttribPointer 告诉 GPU“这一列是位置、那一列是颜色、每列隔多少字节”;最后打开 attribute 开关。这套“描述排布”的机制就是 stride(跳步)和 offset(偏移)。
回看工厂:原料架上一摞摞卡片按产品编号排好,工位处理到几号产品就抽第几张卡。Buffer 的优势正在于此——数据常驻 GPU 显存,流水线多次开动(多次绘制)都不需要重复搬货。
- vtx 顶点编号GPU 当前正在处理的顶点索引,每次调用顶点着色器递增。
- stride 步长一个顶点占用的总字节数,让 GPU 知道“下一行”往右跳多远。
- offset_i 偏移第 i 个属性在行内的起始位置,例如位置从 0 字节开始,颜色从第 12 字节开始。
- 整体取址把行号乘步长、加上列偏移,就能从 Buffer 里精确取出当前顶点第 i 个 attribute。
Varying插值——流水线上的流转单
整形工位做完一个毛坯,会在流转单上写:当前温度 80℃、表面粗糙度 3。可传送带之后要处理的半成品数量多得多:一个三角形明明只有 3 个顶点,经光栅化扩散后却要生成上千个像素片元,每个片元都要一张流转单。GPU 的解法很巧妙:在每个像素的位置,按它离两个端点的远近,把端点上的流转单数值线性混合出一张新单子。
这就是 Varying:顶点着色器输出原始值,片元着色器得到的是插值后的值。前面公式里的 gl_Position 是内置输出,而用户自定义的 varying 同样遵循这条规则。颜色渐变、法线插值、UV 平滑过渡、雾效浓度变化,全靠它在两个着色器阶段之间“打圆场”。
想象传送带:两端放着一红一蓝两种颜料,中间经过的每一个半成品,都自动按距离混合出从红到蓝的平滑渐变——这就是为什么一个纯色三角形,只要给三个顶点不同的颜色,内部就能自动呈现渐变效果。
- Varying_A 与 Varying_B三角形边上两个端点的 varying 值,由顶点着色器写出。
- t 比例当前片元在两个端点之间的相对位置,由重心坐标或线性比例算出。
- 混合运算按 t 对每个分量分别做加权平均,得到该片元的 varying 值。
数据流动管线——从车间主任到成品小球
把三类变量放进一条完整流水线走一遍。CPU 每帧要做三件事:改公告栏(更新 uniform)→ 把原料卡搬上架(创建或更新 VBO)→ 按下开工按钮(发出 draw call)。GPU 随即启动三条流水段:顶点着色器一次处理一个顶点,用 attribute 读自己的原料卡、用 uniform 读公告栏,算出顶点位置并写 varying;光栅化单元在三角形内部生成海量片元,同时把 varying 插值好;片元着色器拿着插值后的 varying 与 uniform 完成最终上色;最后一块像素被写进颜色缓冲,一帧画面完成。
你会发现,三类变量在管线里各就各位、各司其职:Uniform 从头到尾都能被读取,是常驻的“环境变量”;Buffer 只服务顶点着色器,喂的是逐顶点原料;Varying 只存在于 VS 到 FS 之间,是阶段间的传话筒。
从车间主任按下按钮,到彩色小球滚下产线,中间所有数据搬运都由这三条通道完成。你只要在脑子里跑一遍这条流水线,遇到再复杂的 shader 也能瞬间定位“这里的数据是从哪条通道来的”。
- buf 输入顶点缓冲数据,包含每个顶点的位置、颜色、法线等 attribute。
- f_VS 顶点着色器逐顶点执行,输出裁剪坐标与自定义 varying。
- lerp 光栅化插值把顶点上的 varying 插值到每个片元。
- f_FS 片元着色器逐片元执行,输出最终颜色。
对比与选型——三兄弟怎么选
回到车间:公告栏、原料卡、流转单,到底什么时候用哪个?诀窍是看三个问题:数据有几份、谁读谁写、存在哪里。Uniform 只有一份,CPU 写 GPU 只读,存在 GPU 的常量存储区;Buffer 有 N 份顶点数据,CPU 批量写、GPU 按顶点编号读,存在显存堆里;Varying 也有 N 份,但它是 VS 写、FS 读,数据在寄存器与插值单元之间流转,不经过内存。
选型经验可以浓缩成三句话:全局小量用 Uniform,逐顶点大数组用 Buffer,阶段间传递用 Varying。性能上还有几个潜规则:频繁切换 uniform 有开销但通常可接受;每帧全量重建大 VBO 是常见的渲染瓶颈,能复用就复用;varying 里别做昂贵计算,因为每个像素都会执行一遍片元着色器,代价会被放大几百倍。
| 维度 | Uniform | Buffer / attribute | Varying |
|---|---|---|---|
| 数据份数 | 1 份 | N 份(每顶点) | N 份(每片元) |
| 谁写 | CPU | CPU 批量 | 顶点着色器 |
| 谁读 | 所有阶段 | 顶点着色器 | 片元着色器 |
| 存哪里 | 常量存储区 | 显存堆 | 寄存器+插值单元 |
| 典型用途 | 矩阵/时间/光源 | 位置/法线/UV | 颜色渐变/纹理坐标 |
拿一张小卡片记下这组对比,你以后写 shader 时选变量就像老工人挑工具一样顺手。
- U 常量值uniform 在 GPU 中只有一份物理存储,所有线程拿到相同数值。
- buf[i] 索引取值attribute 型数据按顶点编号离散取用,每个顶点互不相同。
- lerp 函数varying 型在光栅化时为每个片元动态计算插值。
AI 应用场景
实时 3D 场景中的光照与相机
阶段:数据组织与 GPU 渲染管线
解决的问题:传统动画循环里,CPU 每帧都要把相机位置、光源方向、模型矩阵传给 GPU。如果逐顶点拷贝这些数据,带宽立刻被挤爆。用 Uniform 做广播,一次提交全场景共享;模型顶点数据放入 Buffer 常驻显存,避免重复搬运。
具体案例:在一个三人称 3D 游戏里,玩家转动视角时,CPU 每帧更新 view/projection 矩阵到 uniform,场景中成千上万个静态模型的顶点坐标全在 VBO 里按兵不动,只有矩阵在变。光照计算需要的法线,通过 varying 从顶点着色器插值到每个片元,让模型表面呈现平滑明暗过渡。
数据可视化大屏的海量散点图
阶段:可视化渲染性能优化
解决的问题:百万级数据点如果每个点都走 CPU 绘制,帧率会跌到个位数。把点的坐标、数值、类别存进 Buffer,GPU 一次性批量处理;全局的缩放比例、旋转角度、颜色映射范围用 uniform 控制,交互时只改几个 uniform 就能瞬时重绘。
具体案例:一个城市交通可视化大屏展示 50 万个出行起点终点。用户拖拽旋转视角时,CPU 只更新 view 矩阵这个 uniform,顶点位置仍由 Buffer 里的原始经纬度数据支撑;散点颜色利用 varying 在顶点间插值,形成从蓝到红的密度渐变,一眼看清热点区域。
粒子系统的全局参数控制
阶段:GPU 粒子模拟与渲染
解决的问题:烟花、雨雪、星空等粒子特效,粒子数量动辄数万。每个粒子的位置、速度、颜色逐帧变化,适合存储在 Buffer 中由 CPU 更新上传;而时间、重力加速度、全局颜色淡出系数这些控制参数,用 uniform 每帧提交一次,效率远高于混进顶点数据。
具体案例:一个 AI 生成的 3D 粒子艺术装置中,50000 个粒子的坐标存入 VBO,CPU 每帧用物理方程更新粒子位置后整体上传;全局的“时间 t”以 uniform 形式传入片元着色器,控制粒子透明度与颜色随时间脉动,营造呼吸般的动态效果。
Python 演示
TRIANGLE = [ (0.0, 0.0, 1.0, 0.0, 0.0), (1.0, 0.0, 0.0, 1.0, 0.0), (0.0, 1.0, 0.0, 0.0, 1.0),] class Uniforms: def __init__(self, brightness=1.0, color_scale=1.0): self.brightness = brightness self.color_scale = color_scale def vertex_shader(attr, uniforms): x, y, r, g, b = attr varying = { "pos": (x, y), "color": (r * uniforms.color_scale, g * uniforms.color_scale, b * uniforms.color_scale), } return varying def pixel_shader(varying, uniforms): r, g, b = varying["color"] bri = uniforms.brightness return (min(r * bri, 1.0), min(g * bri, 1.0), min(b * bri, 1.0)) def lerp(a, b, t): return a + (b - a) * t def interpolate(v_a, v_b, t): return { "pos": (lerp(v_a["pos"][0], v_b["pos"][0], t), lerp(v_a["pos"][1], v_b["pos"][1], t)), "color": (lerp(v_a["color"][0], v_b["color"][0], t), lerp(v_a["color"][1], v_b["color"][1], t), lerp(v_a["color"][2], v_b["color"][2], t)), } def render_edge(t=0.5, width=4, height=4): uniforms = Uniforms(brightness=1.0) vs = [vertex_shader(attr, uniforms) for attr in TRIANGLE] grid = [[(0, 0, 0) for _ in range(width)] for _ in range(height)] v = interpolate(vs[0], vs[1], t) color = pixel_shader(v, uniforms) px = int(v["pos"][0] * (width - 1)) py = int(v["pos"][1] * (height - 1)) grid[py][px] = color return grid, vs[0]["color"], v["color"] if __name__ == "__main__": grid, c0, c_mid = render_edge(t=0.5) for row in grid: print(" ".join("OK" if p != (0, 0, 0) else "--" for p in row)) print("varying 起点颜色:", c0) print("插值中点颜色:", c_mid)
TRIANGLE = [这就是 Buffer 顶点缓冲,三行数据代表三个顶点,每行依次是坐标和 RGB 颜色。class Uniforms:模拟 Uniform 公告栏,brightness和color_scale是全局共享参数。def vertex_shader(attr, uniforms):顶点着色器入口,参数里 attr 来自 Buffer,uniforms 来自公告栏。"color": (r * uniforms.color_scale,varying 输出时先把 uniform 的缩放系数作用到颜色上,体现“VS 读 uniform、写 varying”。def pixel_shader(varying, uniforms):片元着色器:只读 varying 和 uniform,输出最终颜色。def interpolate(v_a, v_b, t):光栅化插值,对 pos 和 color 每个分量做线性 lerp。vs = [vertex_shader(attr, uniforms) for attr in TRIANGLE]逐顶点跑一遍顶点着色器,得到每个顶点的 varying。v = interpolate(vs[0], vs[1], t)在边 AB 上取 t=0.5 处做插值,模拟片元接收到的 varying。grid[py][px] = color把片元颜色写进像素网格,对应渲染管线的最终输出阶段。
常见误区
误区我刚学的时候以为 uniform 就是普通全局变量,手滑在片元着色器里写 `uniform.x -= 0.1;`,心想反正所有地方都能访问,应该也能改。
正解Uniform 在 GPU 侧是严格只读的,只有 CPU 能通过 gl.uniform* 系列函数更新它。这很像车间公告栏:工人只能抬头看,不能拿笔涂改。如果着色器里需要可变中间值,应该用局部变量或 varying,而不是跟 uniform 较劲。
为何常见:因为 CPU 端的全局变量(比如 C 语言的 static)本来就允许读写,初学者很自然地把“全局可见”和“可写”绑定在一起,忽略了 GPU 并行执行时如果允许乱改共享值,结果将完全不可控。
误区看到 varying 出现在顶点着色器和片元着色器里,就以为它和 uniform 一样,是 CPU 塞给 GPU 的数据通道。
正解Varying 完全活在 GPU 内部,CPU 根本摸不到它。它的数据流方向是固定的:顶点着色器写、光栅化插值、片元着色器读。想从 CPU 进 GPU,只能走 uniform 或 buffer 这两条路。
为何常见:初学阶段很容易把“shader 里能看到的变量”都当成外部输入,忽略了着色器程序之间也有自己的内部通信协议,Varying 正是这个协议的信使。
误区写代码时图省事,每帧都重新 `createBuffer` + `bufferData`,觉得反正数据变了就要重新传,不然 GPU 会拿到旧数据。
正解VBO 一旦创建就常驻显存,可以跨帧、跨 draw call 反复使用。正确的做法是:静态几何体创建一次用到底;动态数据优先用 gl.bufferSubData 更新局部区域,而不是整块销毁重建。每帧全量重建缓冲区,就像每次做饭都把厨房拆了重盖,纯属浪费。
为何常见:新手写的第一版渲染循环往往是“初始化一次性 + 每帧绘制”,看到数据变化就本能地走“重新初始化”的路径,还没建立显存生命周期管理的心智模型。
误区面试时被问到“attribute 存在哪里”,我答“存在 attribute 里”,其实根本不知道 attribute 和 VBO 是一个物体的两面。
正解attribute 是着色器语言层面的“逻辑接口名”,描述顶点数据的语义(这是位置、这是颜色);VBO 是 GPU 显存里的“物理存储”(一堆字节)。两者靠 gl.bindBuffer + gl.vertexAttribPointer 绑定在一起,就像“身份证号”和“档案柜里的那份档案”的关系——前者是编号,后者是实物。
为何常见:教程里讲 shader 代码时频繁出现 attribute,讲 GL API 时又频繁出现 VBO,两套术语在不同章节出现,初学者很难自动把逻辑概念和物理存储缝合起来。
误区uniform 读取高效、所有线程共享,那我把几十万个粒子的坐标全塞进 uniform 数组不就行了?省得折腾 VBO。
正解Uniform 存储区容量很有限(通常只有几百个 vec4 的量级),而且它面向的是“一帧内不变”的数据。逐顶点、海量、高频变化的数据硬塞 uniform,轻则编译报错,重则拖垮性能。大数组的正确归宿是 Buffer 或纹理,由 GPU 按索引高效批量读取。
为何常见:Uniform 的 API 用起来简单(一个函数传参),而 Buffer 要绑定、要描述布局、要启用 attribute,步骤多显得麻烦。人总是倾向于用最省事的工具,哪怕它设计上并不适合这个场景。
面试题
Q1. 关于 Uniform 变量,下列说法正确的是?
- A.它可以在片元着色器中随意修改
- B.它保存每个顶点独有的位置数据
- C.它由 CPU 写入、GPU 只读共享← 正确答案
- D.它只在顶点着色器中可见
答案:C。Uniform 是 CPU 向 GPU 提交的全局只读数据,所有着色器阶段都能读到同一份值。A 错在着色器侧只读;B 是 attribute/Buffer 的职责;D 错在 Uniform 在 vertex 和 fragment 阶段都可见。
Q2. Varying 变量的主要作用是什么?
- A.从 CPU 向 GPU 传递全局常量
- B.在顶点和片元着色器之间传递插值数据← 正确答案
- C.存储三角形顶点的索引顺序
- D.在显存中保存顶点数组
答案:B。Varying 由顶点着色器输出、光栅化插值、片元着色器读取。A 说的是 Uniform,D 说的是 VBO/Buffer,C 与变量传递无关。
Q3. 顶点缓冲 VBO 中通常存放什么数据?
- A.每帧更新的全局时间
- B.只读的变换矩阵
- C.每个顶点的位置、法线、UV← 正确答案
- D.片元着色器的输出颜色
答案:C。VBO 存的是逐顶点的几何与属性数据,比如坐标、法线、纹理坐标。全局时间和矩阵属于 Uniform,输出颜色是片元着色器的产物。
Q4. 三角形三个顶点颜色经 varying 输出后,内部某像素得到的颜色是?
- A.三个顶点颜色的加权混合值← 正确答案
- B.三个顶点颜色的随机一个值
- C.三个顶点颜色的算术平均值
- D.由 uniform 单独决定的值
答案:A。光栅化阶段对 varying 做线性插值,片元离哪个顶点近就偏向那个颜色,是加权混合而非简单平均或随机。这个插值过程与 uniform 无关,所以 D 错。
Q5. 下列哪些数据适合用 Uniform 传递?
- A.模型视图投影矩阵← 正确答案
- B.当前动画时间← 正确答案
- C.几十万粒子的位置数组
- D.全局光源方向向量← 正确答案
答案:ABD。矩阵、时间、光源方向都是“一帧内全体共享、量小”的数据,适合 Uniform。几十万粒子的位置是逐顶点大数组,应该放 Buffer 中按索引读取,硬塞 uniform 会超过容量限制。
Q6. 关于 Buffer(VBO)与 attribute 的关系,正确的是?
- A.attribute 是着色器里读取顶点数据的逻辑接口← 正确答案
- B.VBO 存放在 GPU 显存中,可跨帧重复使用← 正确答案
- C.每次绘制前都必须重建 VBO 才能保证数据正确
- D.vertexAttribPointer 负责描述数据排布与取址← 正确答案
答案:ABD。attribute 是逻辑变量名,VBO 是物理存储,两者通过 vertexAttribPointer 绑定。VBO 常驻显存可复用,C 的“每次重建”是典型性能误区。
Q7. 光栅化阶段中,片元收到的 varying 数据是怎么来的?
- A.每个片元自动生成一份插值后的 varying← 正确答案
- B.只有三角形中心才有一份 varying
- C.varying 的值只在顶点上有效
- D.片元数量与插值完全不相关
答案:A。光栅化把三角形覆盖成大量像素,同时按像素位置对顶点 varying 做线性插值,因此每个片元都有自己的插值结果。B、C 都否定了插值的存在;D 则完全说反了。
Q8. 下面哪种做法最容易导致渲染性能问题?
- A.每帧把视角矩阵写入 uniform
- B.把逐顶点位置数组放在 VBO 中
- C.每帧全量重新上传几十万顶点到 GPU← 正确答案
- D.在顶点着色器里计算顶点位置
答案:C。每帧全量重建大 VBO 会产生海量 PCIe 带宽消耗,是经典性能瓶颈。A 的 uniform 更新开销很小,B 是正确用法,D 本来就是顶点着色器的本职。
Q9. 一个三角形只有 3 个顶点,片元着色器大概会执行多少次?
- A.恰好 3 次
- B.等于三角形覆盖的像素数量← 正确答案
- C.与顶点数完全没有关联
- D.只执行 1 次
答案:B。顶点着色器每个顶点执行一次,片元着色器每个片元(像素)执行一次。三角形覆盖几百个像素,片元着色器就跑几百次,这正是 varying 需要插值的原因。
Q10. 关于三类变量的对比,下列正确的有?
- A.Uniform 容量小但读取高效,适合少量全局数据← 正确答案
- B.Buffer 适合大量逐顶点数据,可长期驻留显存← 正确答案
- C.Varying 各字段在光栅化时自动线性插值← 正确答案
- D.Uniform 可以为每个顶点保存不同的数值
答案:ABC。Uniform 是“一份共享”的只读常量,容量有限所以只装少量全局数据;Buffer 是逐顶点大数组的归属;Varying 由光栅化自动插值。D 说反了——Uniform 是所有顶点共享同一份值,逐顶点不同是 Buffer 的职责。