一、前言
1.1 适用人群
本教程适合已经了解WebGL基本概念,知道着色器的作用,但对数据如何从CPU传到GPU感到困惑的开发者。我们将聚焦于VBO(Vertex Buffer Object) 这个核心机制,通过一个生动的案例,彻底搞懂为什么必须用数组,以及如何正确传递数据。
效果图

1.2 核心痛点与目标
很多初学者会问:“我明明在JS里定义了 {x:0.5, y:0.5, color:'red'} 这样的对象,为什么不能直接传给着色器?”
-
理解本质:掌握VBO的概念,理解GPU需要的是连续二进制数据而非JS对象。
-
掌握方法:学会创建VBO、传输数据、设置属性指针的完整流程。
-
实战应用:通过一个彩色点云的完整代码示例,亲手实现数据从CPU到GPU的旅程。
二、核心概念:为什么必须传数组?
2.1、一句话理解
VBO(Vertex Buffer Object) 就是 CPU 到 GPU 的「数据传送带」——你把顶点数据(位置、颜色等)打包成数组,通过VBO送到GPU显存,然后着色器从这个传送带上按需取用。
2.2 GPU是并行工厂,不是单任务处理器
| 工作方式 | 串行执行指令 | 并行处理海量数据 |
| 数据处理 | 一个个处理 | 一批批处理(数组最佳) |
| 内存访问 | 随机访问 | 连续访问最快 |
数组的优势:
-
✅ 内存连续:GPU可以一次性加载大量数据到缓存
-
✅ 批量传输:一次bufferData调用传输整个数组,比逐个传输快几千倍
-
✅ 并行处理:所有顶点数据格式一致,GPU可以同时处理
2.3 数组 vs JS对象:性能天壤之别
javascript
// ❌ GPU无法直接理解的方式
const vertex = { x: 0.5, y: 0.5, r: 1.0, g: 0.0, b: 0.0 };
// ✅ GPU喜欢的方式
const vertex = new Float32Array([0.5, 0.5, 1.0, 0.0, 0.0]);
-
对象:属性名是字符串,内存布局分散,GPU无法批量读取。
-
数组:内存连续紧凑,GPU可以一次性加载一大块到缓存,所有核心并行处理。
2.4 数组 = 数据蓝图
javascript
// 假设你要画一个三角形(3个顶点)
const vertices = new Float32Array([
// 每个顶点占用 5 个数字:位置(x,y) + 颜色(r,g,b)
-0.5, -0.5, 1.0, 0.0, 0.0, // 顶点0:左下角,红色
0.5, -0.5, 0.0, 1.0, 0.0, // 顶点1:右下角,绿色
0.0, 0.5, 0.0, 0.0, 1.0 // 顶点2:顶部,蓝色
]);
这个数组就是给GPU的完整蓝图,告诉它每个顶点长什么样。
三、核心流程:怎么传?七步搞定VBO
3.1 总览流程图
text

3.2 完整代码示例:彩色点云
将以下代码保存为一个HTML文件(如 vbo_explained.html)并运行,你将看到500个彩色点,每个点的位置和颜色都来自我们精心构造的数组。
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebGL基础教程 (七):VBO详解——为什么要传数组?怎么传?</title>
<style>
body { margin: 0; overflow: hidden; background-color: #111; color: white; font-family: 'Microsoft YaHei', sans-serif; }
#info {
position: absolute; top: 20px; left: 20px;
background: rgba(0,0,0,0.85); padding: 20px 30px;
border-radius: 12px; border-left: 6px solid #44aaff;
box-shadow: 0 8px 25px rgba(0,0,0,0.7);
pointer-events: none; z-index: 100;
backdrop-filter: blur(5px);
border: 1px solid rgba(255,255,255,0.1);
line-height: 1.7;
max-width: 500px;
}
#info h2 { margin: 0 0 10px 0; color: #44aaff; font-weight: 400; }
#info h2 i { font-style: normal; color: #fff; font-weight: 300; margin-left: 10px; }
#info p { margin: 8px 0; font-size: 15px; color: #ddd; }
#info .code {
font-family: 'Consolas', monospace;
background: #2a2a2a;
padding: 2px 8px;
border-radius: 4px;
color: #ffaa66;
border: 1px solid #444;
}
#info .step {
display: inline-block;
background: #1e3a2a;
color: #8affb8;
padding: 3px 10px;
border-radius: 20px;
margin: 5px 5px 0 0;
font-size: 12px;
border-left: 3px solid #00cc88;
}
#memory-layout {
position: absolute;
bottom: 20px;
left: 20px;
right: 20px;
background: rgba(0,0,0,0.9);
padding: 15px 25px;
border-radius: 8px;
color: #88ff88;
font-family: monospace;
font-size: 14px;
border-left: 4px solid #ffaa00;
border-right: 4px solid #ffaa00;
z-index: 100;
text-align: center;
max-width: 900px;
margin: 0 auto;
}
</style>
</head>
<body>
<div id="info">
<h2>📦 VBO深度解析 <i>—— 为什么是数组?</i></h2>
<p>🎯 绘制内容: <strong style="color:#fff;">500个彩色点</strong></p>
<p>📊 数据格式: <span class="code">[x, y, r, g, b]</span> (每个点5个float)</p>
<p>💾 数组总长: <span class="code">500 × 5 = 2500</span> 个数字</p>
<p>🚀 传输字节: <span class="code">2500 × 4 = 10,000</span> 字节 (~10KB)</p>
<div style="margin-top: 15px;">
<span class="step">① 创建Float32Array</span>
<span class="step">② 创建VBO</span>
<span class="step">③ 绑定</span>
<span class="step">④ bufferData</span>
<span class="step">⑤ 属性指针</span>
</div>
</div>
<div id="memory-layout">
<span style="color:#ffaa66;">内存布局: [顶点0] (x, y, r, g, b) | [顶点1] (x, y, r, g, b) | [顶点2] …</span>
<br>
<span style="color:#88ff88;">步长(stride) = 20字节 | 位置偏移(offset) = 0 | 颜色偏移 = 8字节</span>
</div>
<script>
(function() {
// — 1. 初始化WebGL —
const canvas = document.createElement('canvas');
document.body.appendChild(canvas);
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const gl = canvas.getContext('webgl');
if (!gl) { alert('您的浏览器不支持WebGL'); return; }
// — 2. 定义着色器 (GLSL) —
const vsSource = `
attribute vec2 aPosition; // 从VBO接收位置
attribute vec3 aColor; // 从VBO接收颜色
varying vec3 vColor; // 传递给片元着色器
void main() {
gl_Position = vec4(aPosition, 0.0, 1.0);
gl_PointSize = 5.0; // 点大小
vColor = aColor;
}
`;
const fsSource = `
precision mediump float;
varying vec3 vColor;
void main() {
gl_FragColor = vec4(vColor, 1.0);
}
`;
// — 3. 编译着色器并链接程序 (标准模板) —
const program = (() => {
const vs = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vs, vsSource);
gl.compileShader(vs);
const fs = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fs, fsSource);
gl.compileShader(fs);
const prog = gl.createProgram();
gl.attachShader(prog, vs);
gl.attachShader(prog, fs);
gl.linkProgram(prog);
gl.useProgram(prog);
return prog;
})();
// — 4. 【第一步:准备数据】为什么是数组?因为GPU要批量处理!—
const POINT_COUNT = 500;
// 每个点占用5个float: [x, y, r, g, b]
const vertexData = new Float32Array(POINT_COUNT * 5);
// 填充数据:随机位置和颜色
for (let i = 0; i < POINT_COUNT; i++) {
const baseIndex = i * 5;
// 位置 (范围 -1 到 1)
vertexData[baseIndex] = (Math.random() * 2 – 1); // x
vertexData[baseIndex + 1] = (Math.random() * 2 – 1); // y
// 颜色 (范围 0 到 1)
vertexData[baseIndex + 2] = Math.random(); // r
vertexData[baseIndex + 3] = Math.random(); // g
vertexData[baseIndex + 4] = Math.random(); // b
}
console.log('📦 数据数组已创建:');
console.log(' – 类型:', vertexData.constructor.name);
console.log(' – 长度:', vertexData.length, '个数字');
console.log(' – 字节数:', vertexData.byteLength, '字节');
console.log(' – 前5个数字(第一个点):',
vertexData[0].toFixed(2), vertexData[1].toFixed(2),
vertexData[2].toFixed(2), vertexData[3].toFixed(2), vertexData[4].toFixed(2));
// — 5. 【第二步:创建VBO】在GPU上分配一个“盒子”—
const vbo = gl.createBuffer();
console.log('✅ 创建VBO成功, ID:', vbo);
// — 6. 【第三步:绑定VBO】告诉WebGL我们要操作这个“盒子”—
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
console.log('✅ 绑定VBO到 ARRAY_BUFFER 目标');
// — 7. 【第四步:传输数据】把数组从CPU内存拷贝到GPU显存—
// 第三个参数 gl.STATIC_DRAW 提示GPU:这些数据不会改变
gl.bufferData(gl.ARRAY_BUFFER, vertexData, gl.STATIC_DRAW);
console.log('✅ 数据传输完成,大小:', vertexData.byteLength, '字节');
// — 8. 【第五步:获取属性位置】找到着色器里的变量—
const aPosition = gl.getAttribLocation(program, 'aPosition');
const aColor = gl.getAttribLocation(program, 'aColor');
console.log('✅ 属性位置: aPosition =', aPosition, ', aColor =', aColor);
// — 9. 【第六步:设置属性指针】最关键的一步!—
// 告诉GPU如何从二进制数组中解析出每个顶点的属性
// 计算步长:每个顶点占用多少字节
const FLOATS_PER_VERTEX = 5; // x,y,r,g,b
const BYTES_PER_FLOAT = 4; // Float32是4字节
const stride = FLOATS_PER_VERTEX * BYTES_PER_FLOAT; // 20字节
console.log('📐 属性指针计算:');
console.log(' – 每个顶点占用', FLOATS_PER_VERTEX, '个float');
console.log(' – 步长(stride) =', stride, '字节');
// 设置位置属性 (从每个顶点的第0字节开始读)
gl.vertexAttribPointer(
aPosition, // 着色器中的属性索引
2, // 取2个数字 (x, y)
gl.FLOAT, // 数据类型
false, // 不需要归一化
stride, // 步长
0 // 偏移量 0 字节
);
gl.enableVertexAttribArray(aPosition);
console.log(' ✅ 位置属性: 偏移0字节, 取2个float');
// 设置颜色属性 (从每个顶点的第8字节开始读)
const colorOffset = 2 * BYTES_PER_FLOAT; // 8字节
gl.vertexAttribPointer(
aColor, // 着色器中的属性索引
3, // 取3个数字 (r, g, b)
gl.FLOAT, // 数据类型
false, // 不需要归一化
stride, // 步长
colorOffset // 偏移量 8 字节
);
gl.enableVertexAttribArray(aColor);
console.log(' ✅ 颜色属性: 偏移8字节, 取3个float');
// — 10. 清理绑定 (好习惯) —
gl.bindBuffer(gl.ARRAY_BUFFER, null);
// — 11. 绘制!—
gl.clearColor(0.05, 0.05, 0.1, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
// 绘制点云:从第0个顶点开始,一共绘制 POINT_COUNT 个点
gl.drawArrays(gl.POINTS, 0, POINT_COUNT);
console.log('🎨 绘制完成,绘制了', POINT_COUNT, '个点');
// — 12. 添加动态效果:每帧更新颜色(演示DYNAMIC_DRAW)—
// 这部分是进阶内容,展示如果数据变化应该怎么做
setTimeout(() => {
console.log('🔄 演示动态更新:5秒后将颜色改为红色渐变');
// 创建新数据(只改变颜色,位置不变)
const newColors = new Float32Array(POINT_COUNT * 3);
for (let i = 0; i < POINT_COUNT; i++) {
newColors[i*3] = 1.0; // R = 1
newColors[i*3+1] = i / POINT_COUNT; // G 渐变
newColors[i*3+2] = 0.0; // B = 0
}
// 注意:这里为了简单,我们重新上传了整个缓冲区
// 实际优化中应该只更新变化的部分 (bufferSubData)
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
// 创建一个混合数组:保留原位置,更新颜色
const mixedData = new Float32Array(POINT_COUNT * 5);
for (let i = 0; i < POINT_COUNT; i++) {
// 读取原位置 (从vertexData)
mixedData[i*5] = vertexData[i*5];
mixedData[i*5+1] = vertexData[i*5+1];
// 设置新颜色
mixedData[i*5+2] = newColors[i*3];
mixedData[i*5+3] = newColors[i*3+1];
mixedData[i*5+4] = newColors[i*3+2];
}
gl.bufferData(gl.ARRAY_BUFFER, mixedData, gl.STATIC_DRAW);
// 重新绘制
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.POINTS, 0, POINT_COUNT);
console.log('✅ 颜色更新完成');
}, 5000);
// — 窗口大小自适应 —
window.addEventListener('resize', () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
gl.viewport(0, 0, canvas.width, canvas.height);
});
})();
</script>
</body>
</html>
四、深度解析:vertexAttribPointer 的魔法
这个函数是VBO的灵魂,它就像一张数据解析地图,告诉GPU:
“听着,我的数组里每个顶点占用20字节。你要找的位置数据,在每个顶点的开头,取2个float;颜色数据,在开头往后数8字节的位置,取3个float。”
javascript
gl.vertexAttribPointer(
index, // 着色器属性的位置 (0,1,2…)
size, // 每个顶点取几个分量 (1-4)
type, // 数据类型 (gl.FLOAT, gl.UNSIGNED_BYTE等)
normalized, // 是否归一化 (颜色常用true)
stride, // 步长:两个相邻顶点之间的字节数
offset // 偏移:从顶点哪里开始读
);
五、数组格式的两种布局策略
| 交错布局 | [x,y,r,g,b, x,y,r,g,b, …] | 缓存友好,所有属性一起加载 | 更新部分属性较复杂 |
| 分离布局 | [x,y, x,y, …] + [r,g,b, r,g,b, …] | 可单独更新位置或颜色 | 需要多个VBO,可能缓存不友好 |
本例采用交错布局,这是最常用也最高效的方式。
六、常见问题解答
Q1: 为什么用 Float32Array 而不是普通数组?
A: GPU直接读取二进制内存,Float32Array 就是二进制数据块。普通JS数组是对象,无法直接传输。
Q2: bufferData 的第三个参数 usage 有什么用?
A: 给GPU一个优化提示:
-
gl.STATIC_DRAW: 数据几乎不变(如模型顶点)
-
gl.DYNAMIC_DRAW: 数据频繁变化(如粒子位置)
-
gl.STREAM_DRAW: 每帧都变(如流式数据)
Q3: 一个VBO可以传多少数据?
A: 理论上是显存大小,实际中几十MB都没问题。本例10KB微不足道。
七、结语
通过本文你应该深刻理解:VBO就是一座桥,用数组这种最朴实的形式,让JS和GPU能高效沟通。掌握 Float32Array + bufferData + vertexAttribPointer 这个铁三角,你就掌握了WebGL数据流的精髓。
快去修改代码中的 POINT_COUNT 试试看,你会发现即使增加到5000个点,依然流畅——这就是数组批量处理的威力!
感谢阅读! 喜欢本文请不要吝啬你的 一键三连:点赞、收藏、留言,让更多小伙伴看到这份干货!



