第4章:缓冲区与顶点数据
4.1 章节概述
在前面的章节中,我们已经使用缓冲区存储顶点数据,但只是浅尝辄止。本章将深入探讨 WebGL 的缓冲区系统——这是 CPU 与 GPU 之间数据传输的桥梁。
理解缓冲区对于:
- 性能优化:减少 CPU-GPU 数据传输
- 复杂模型:管理大量顶点数据
- 动态效果:高效更新动画数据
本章将详细讲解:
- 缓冲区对象:创建、绑定、数据上传
- 顶点属性配置:vertexAttribPointer 的精确使用
- 数据布局策略:交错 vs 分离
- 索引缓冲:顶点复用
- VAO:状态封装
- 动态数据更新:高效的数据修改
4.2 缓冲区基础概念
4.2.1 什么是缓冲区?
缓冲区(Buffer) 是 GPU 显存中的一块连续内存区域,用于存储顶点数据、索引数据等。
缓冲区在 WebGL 架构中的位置
┌──────────────────────────────────────────────────────────────┐
│ CPU 内存 │
│ │
│ JavaScript 类型化数组 │
│ ┌─────────────┐ ┌───────────────────────┐ │
│ │ const data │ ─────────→ │ Float32Array │ │
│ │ = [...] │ │ [x0,y0,z0,x1,y1,z1...]│ │
│ └─────────────┘ └───────────┬───────────┘ │
│ │ │
└────────────────────────────────────────────│─────────────────┘
│
│ gl.bufferData()
│ (数据复制到 GPU)
▼
┌──────────────────────────────────────────────────────────────┐
│ GPU 显存 │
│ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ 缓冲区对象 (Buffer) │ │
│ │ [x0,y0,z0,x1,y1,z1,x2,y2,z2,...] │ │
│ │ │ │
│ │ 特点: │ │
│ │ - 位于高速显存中 │ │
│ │ - GPU 可以快速并行访问 │ │
│ │ - 数据传输后,不需要每帧重传 │ │
│ └────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘4.2.2 缓冲区类型
WebGL 支持多种缓冲区类型:
| 缓冲区类型 | 常量 | 用途 |
|---|---|---|
| 数组缓冲区 | gl.ARRAY_BUFFER | 顶点属性数据(位置、颜色、法线等) |
| 索引缓冲区 | gl.ELEMENT_ARRAY_BUFFER | 顶点索引 |
| 统一缓冲区 | gl.UNIFORM_BUFFER | uniform 块(WebGL 2.0) |
| 变换反馈缓冲区 | gl.TRANSFORM_FEEDBACK_BUFFER | 变换反馈输出(WebGL 2.0) |
| 像素缓冲区 | gl.PIXEL_PACK_BUFFER | 像素读取(WebGL 2.0) |
| 像素解包缓冲区 | gl.PIXEL_UNPACK_BUFFER | 像素上传(WebGL 2.0) |
| 复制读缓冲区 | gl.COPY_READ_BUFFER | 缓冲区复制源(WebGL 2.0) |
| 复制写缓冲区 | gl.COPY_WRITE_BUFFER | 缓冲区复制目标(WebGL 2.0) |
4.2.3 类型化数组
JavaScript 中的普通数组不能直接传给 GPU,必须使用类型化数组(TypedArray):
javascript
// 类型化数组与 WebGL 数据类型的对应关系
// 浮点数(最常用于顶点数据)
const float32 = new Float32Array([1.0, 2.0, 3.0]); // gl.FLOAT
// 无符号整数(常用于索引)
const uint16 = new Uint16Array([0, 1, 2]); // gl.UNSIGNED_SHORT
const uint32 = new Uint32Array([0, 1, 2]); // gl.UNSIGNED_INT
// 有符号整数
const int32 = new Int32Array([1, -2, 3]); // gl.INT
const int16 = new Int16Array([1, -2, 3]); // gl.SHORT
// 无符号字节(常用于颜色)
const uint8 = new Uint8Array([255, 128, 0]); // gl.UNSIGNED_BYTE
// 有符号字节
const int8 = new Int8Array([127, -128, 0]); // gl.BYTE
// 类型化数组的常用操作
const array = new Float32Array(100); // 创建 100 个元素的数组
const copy = new Float32Array(existingArray); // 从现有数组创建
const fromJS = new Float32Array([1, 2, 3, 4]); // 从 JS 数组创建
// 获取字节信息
array.BYTES_PER_ELEMENT; // 4(float32 是 4 字节)
array.byteLength; // 总字节数
array.length; // 元素数量
// 创建视图(共享同一块内存)
const buffer = new ArrayBuffer(24); // 24 字节的原始缓冲区
const floats = new Float32Array(buffer); // 作为 float 访问(6 个)
const ints = new Uint32Array(buffer); // 作为 uint32 访问(6 个)4.3 缓冲区操作详解
4.3.1 创建缓冲区
javascript
/**
* 创建缓冲区
* @returns {WebGLBuffer} 缓冲区对象
*/
const buffer = gl.createBuffer();
// createBuffer 返回一个 WebGLBuffer 对象
// 这只是创建了一个空的缓冲区"句柄"
// 还没有分配 GPU 内存4.3.2 绑定缓冲区
javascript
/**
* 绑定缓冲区到目标
* @param {GLenum} target - 缓冲区类型
* @param {WebGLBuffer} buffer - 缓冲区对象
*/
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
// 绑定后,后续对该目标的操作都会影响这个缓冲区
// WebGL 是状态机模型,必须先绑定才能操作
// 解绑(可选,但有助于避免状态污染)
gl.bindBuffer(gl.ARRAY_BUFFER, null);4.3.3 上传数据
javascript
/**
* 上传数据到缓冲区
* @param {GLenum} target - 缓冲区类型
* @param {BufferSource|number} sizeOrData - 数据或大小
* @param {GLenum} usage - 使用提示
*/
// 方式 1:上传数据
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
// 方式 2:只分配空间(不上传数据)
gl.bufferData(gl.ARRAY_BUFFER, 1024, gl.DYNAMIC_DRAW); // 1024 字节
// ============ usage 参数详解 ============
// 使用提示告诉 GPU 如何优化内存分配
// 格式:[读取频率]_[修改频率]
gl.STATIC_DRAW // 数据上传一次,多次用于绘制
// 适用于:静态几何体
gl.DYNAMIC_DRAW // 数据经常修改,多次用于绘制
// 适用于:粒子系统、动画
gl.STREAM_DRAW // 数据每帧都修改
// 适用于:视频帧、流式数据
// WebGL 2.0 新增
gl.STATIC_READ // 数据上传一次,多次从 GPU 读取
gl.DYNAMIC_READ // 数据经常修改,多次从 GPU 读取
gl.STREAM_READ // 数据每帧修改,多次从 GPU 读取
gl.STATIC_COPY // 数据上传一次,用于 GPU 内部复制
gl.DYNAMIC_COPY // 数据经常修改,用于 GPU 内部复制
gl.STREAM_COPY // 数据每帧修改,用于 GPU 内部复制4.3.4 更新部分数据
javascript
/**
* 更新缓冲区的部分数据
* @param {GLenum} target - 缓冲区类型
* @param {number} offset - 字节偏移
* @param {BufferSource} data - 新数据
*/
gl.bufferSubData(gl.ARRAY_BUFFER, offset, data);
// 示例:更新第 10 个顶点的位置(假设每顶点 3 个 float)
const newPosition = new Float32Array([1.0, 2.0, 3.0]);
const vertexIndex = 10;
const bytesPerVertex = 3 * 4; // 3 个 float,每个 4 字节
const offset = vertexIndex * bytesPerVertex;
gl.bufferSubData(gl.ARRAY_BUFFER, offset, newPosition);
// ============ bufferData vs bufferSubData ============
// bufferData: 重新分配整个缓冲区
// - 会删除旧数据
// - 可以改变大小
// - 适合完全替换数据
// bufferSubData: 更新部分数据
// - 保留其他数据不变
// - 不能改变大小
// - 适合局部更新4.3.5 删除缓冲区
javascript
/**
* 删除缓冲区,释放 GPU 内存
* @param {WebGLBuffer} buffer - 缓冲区对象
*/
gl.deleteBuffer(buffer);
// 注意:删除后不要再使用这个缓冲区
// 如果缓冲区还绑定着,会自动解绑4.4 顶点属性配置
4.4.1 vertexAttribPointer 深入理解
vertexAttribPointer 是连接缓冲区数据和着色器属性的关键:
javascript
/**
* 配置顶点属性指针
* @param {number} index - 属性位置
* @param {number} size - 每个顶点的分量数 (1, 2, 3, 或 4)
* @param {GLenum} type - 数据类型
* @param {boolean} normalized - 是否归一化
* @param {number} stride - 步长(字节)
* @param {number} offset - 偏移(字节)
*/
gl.vertexAttribPointer(index, size, type, normalized, stride, offset);参数详解图示:
假设我们有以下交错数据:
位置 (x, y, z) + 颜色 (r, g, b) + 纹理坐标 (u, v)
缓冲区数据:
┌──────────────────────────────────────────────────────────────────┐
│ x0 y0 z0 r0 g0 b0 u0 v0 │ x1 y1 z1 r1 g1 b1 u1 v1 │...
│ ←─────────── 顶点 0 ───────────→ ←─────────── 顶点 1 ───────────→
└──────────────────────────────────────────────────────────────────┘
↑ ↑
│← offset=0 │← offset=stride
│ │
│←────────────── stride ────────────────→│
位置属性 (a_position):
┌───────────────────────────────────────────────────────────────────┐
│ size = 3 (x, y, z) │
│ type = gl.FLOAT │
│ stride = 32 (8 floats × 4 bytes) │
│ offset = 0 │
│ │
│ 数据读取: │
│ 顶点 0: 从 offset=0 读取 3 个 float │
│ 顶点 1: 从 offset=0+stride=32 读取 3 个 float │
│ 顶点 2: 从 offset=0+stride×2=64 读取 3 个 float │
│ ... │
└───────────────────────────────────────────────────────────────────┘
颜色属性 (a_color):
┌───────────────────────────────────────────────────────────────────┐
│ size = 3 (r, g, b) │
│ type = gl.FLOAT │
│ stride = 32 │
│ offset = 12 (3 floats × 4 bytes) │
│ │
│ 数据读取: │
│ 顶点 0: 从 offset=12 读取 3 个 float │
│ 顶点 1: 从 offset=12+stride=44 读取 3 个 float │
│ ... │
└───────────────────────────────────────────────────────────────────┘
纹理坐标属性 (a_texCoord):
┌───────────────────────────────────────────────────────────────────┐
│ size = 2 (u, v) │
│ type = gl.FLOAT │
│ stride = 32 │
│ offset = 24 (6 floats × 4 bytes) │
└───────────────────────────────────────────────────────────────────┘4.4.2 normalized 参数
javascript
// normalized = true 时,整数值会被归一化到 [0, 1] 或 [-1, 1]
// 示例:使用 Uint8 存储颜色(节省内存)
const colors = new Uint8Array([
255, 0, 0, 255, // 红色 (RGBA)
0, 255, 0, 255, // 绿色
0, 0, 255, 255 // 蓝色
]);
// normalized = true: 255 → 1.0, 128 → 0.5, 0 → 0.0
gl.vertexAttribPointer(colorLoc, 4, gl.UNSIGNED_BYTE, true, 0, 0);
// normalized = false: 值保持不变 (255, 0, 0, 255)
gl.vertexAttribPointer(colorLoc, 4, gl.UNSIGNED_BYTE, false, 0, 0);
// 归一化规则:
// 无符号类型: [0, MAX] → [0.0, 1.0]
// - UNSIGNED_BYTE (0-255) → (0.0-1.0)
// - UNSIGNED_SHORT (0-65535) → (0.0-1.0)
//
// 有符号类型: [MIN, MAX] → [-1.0, 1.0]
// - BYTE (-128 to 127) → (-1.0 to 1.0)
// - SHORT (-32768 to 32767) → (-1.0 to 1.0)4.4.3 完整的属性设置流程
javascript
/**
* 设置顶点属性的完整流程
*/
function setupVertexAttribute(gl, program, attrName, size, type, stride, offset) {
// 1. 获取属性位置
const location = gl.getAttribLocation(program, attrName);
if (location === -1) {
console.warn(`属性 ${attrName} 未在着色器中使用`);
return -1;
}
// 2. 启用属性数组
gl.enableVertexAttribArray(location);
// 3. 配置属性指针(假设缓冲区已绑定)
gl.vertexAttribPointer(
location,
size,
type,
false, // 对于浮点数通常是 false
stride,
offset
);
return location;
}
// 使用示例
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
const FSIZE = Float32Array.BYTES_PER_ELEMENT; // 4
const stride = FSIZE * 8; // 8 个 float per vertex
setupVertexAttribute(gl, program, 'a_position', 3, gl.FLOAT, stride, 0);
setupVertexAttribute(gl, program, 'a_color', 3, gl.FLOAT, stride, FSIZE * 3);
setupVertexAttribute(gl, program, 'a_texCoord', 2, gl.FLOAT, stride, FSIZE * 6);4.5 数据布局策略
4.5.1 交错布局 vs 分离布局
两种主要的数据组织方式:
交错布局(Interleaved):
所有属性交错存储在一个缓冲区中
┌─────────────────────────────────────────────────────────┐
│ P0 C0 T0 │ P1 C1 T1 │ P2 C2 T2 │ ... │
│ ←顶点0→ │ ←顶点1→ │ ←顶点2→ │ │
└─────────────────────────────────────────────────────────┘
P = Position (位置)
C = Color (颜色)
T = TexCoord (纹理坐标)
优点:
- 缓存友好(访问一个顶点的所有属性时,数据相邻)
- 单个缓冲区,管理简单
缺点:
- 更新单个属性需要处理整个数据块
- 不同属性的更新频率不同时效率较低javascript
// 交错布局代码示例
const vertexCount = 3;
const floatsPerVertex = 8; // 3 + 3 + 2
const interleavedData = new Float32Array([
// 位置 (3) 颜色 (3) 纹理坐标 (2)
0.0, 0.5, 0.0, 1.0, 0.0, 0.0, 0.5, 1.0, // 顶点 0
-0.5, -0.5, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, // 顶点 1
0.5, -0.5, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0 // 顶点 2
]);
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, interleavedData, gl.STATIC_DRAW);
const FSIZE = Float32Array.BYTES_PER_ELEMENT;
const stride = FSIZE * floatsPerVertex;
gl.vertexAttribPointer(posLoc, 3, gl.FLOAT, false, stride, 0);
gl.vertexAttribPointer(colorLoc, 3, gl.FLOAT, false, stride, FSIZE * 3);
gl.vertexAttribPointer(texCoordLoc, 2, gl.FLOAT, false, stride, FSIZE * 6);分离布局(Separate):
每种属性存储在独立的缓冲区中
位置缓冲区:
┌───────────────────────────────────────────┐
│ P0 │ P1 │ P2 │ P3 │ ... │
└───────────────────────────────────────────┘
颜色缓冲区:
┌───────────────────────────────────────────┐
│ C0 │ C1 │ C2 │ C3 │ ... │
└───────────────────────────────────────────┘
纹理坐标缓冲区:
┌───────────────────────────────────────────┐
│ T0 │ T1 │ T2 │ T3 │ ... │
└───────────────────────────────────────────┘
优点:
- 可以独立更新各个属性
- 不同属性可以有不同的更新策略
缺点:
- 多个缓冲区,管理复杂
- 缓存效率可能较低javascript
// 分离布局代码示例
const positions = new Float32Array([
0.0, 0.5, 0.0,
-0.5, -0.5, 0.0,
0.5, -0.5, 0.0
]);
const colors = new Float32Array([
1.0, 0.0, 0.0,
0.0, 1.0, 0.0,
0.0, 0.0, 1.0
]);
const texCoords = new Float32Array([
0.5, 1.0,
0.0, 0.0,
1.0, 0.0
]);
// 创建三个缓冲区
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);
const colorBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
gl.bufferData(gl.ARRAY_BUFFER, colors, gl.DYNAMIC_DRAW); // 颜色可能经常变
const texCoordBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
gl.bufferData(gl.ARRAY_BUFFER, texCoords, gl.STATIC_DRAW);
// 设置属性时,需要分别绑定对应的缓冲区
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.vertexAttribPointer(posLoc, 3, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
gl.vertexAttribPointer(colorLoc, 3, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
gl.vertexAttribPointer(texCoordLoc, 2, gl.FLOAT, false, 0, 0);4.5.2 选择建议
| 场景 | 推荐布局 | 原因 |
|---|---|---|
| 静态模型 | 交错 | 缓存效率高 |
| 动画(骨骼/变形) | 分离 | 位置经常变,其他属性不变 |
| 粒子系统 | 分离 | 位置每帧更新 |
| 地形 | 分离 | 可能只更新某些属性 |
| 多属性共享 | 分离 | 不同模型可以共享纹理坐标等 |
4.6 索引缓冲
4.6.1 为什么需要索引?
在绘制复杂模型时,很多顶点会被多个三角形共享:
绘制一个矩形
不使用索引(6 个顶点):
0───1
│ ╲ │
2───3
三角形 1: 顶点 0, 2, 1
三角形 2: 顶点 1, 2, 3
需要 6 个顶点数据:
[x0,y0,z0, x2,y2,z2, x1,y1,z1, // 三角形 1
x1,y1,z1, x2,y2,z2, x3,y3,z3] // 三角形 2
顶点 1 和顶点 2 重复存储了!
使用索引(4 个顶点 + 6 个索引):
顶点数据:
[x0,y0,z0, x1,y1,z1, x2,y2,z2, x3,y3,z3] // 只存储 4 个顶点
索引数据:
[0, 2, 1, // 三角形 1
1, 2, 3] // 三角形 2
节省了内存,顶点只存储一次!4.6.2 创建和使用索引缓冲
javascript
// 创建顶点数据
const vertices = new Float32Array([
// 位置
-0.5, 0.5, 0.0, // 顶点 0: 左上
0.5, 0.5, 0.0, // 顶点 1: 右上
-0.5, -0.5, 0.0, // 顶点 2: 左下
0.5, -0.5, 0.0 // 顶点 3: 右下
]);
// 创建索引数据
const indices = new Uint16Array([
0, 2, 1, // 三角形 1
1, 2, 3 // 三角形 2
]);
// 创建顶点缓冲区
const vertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
// 创建索引缓冲区
const indexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);
// 设置顶点属性
gl.enableVertexAttribArray(positionLoc);
gl.vertexAttribPointer(positionLoc, 3, gl.FLOAT, false, 0, 0);
// 使用 drawElements 代替 drawArrays
gl.drawElements(
gl.TRIANGLES, // 图元类型
indices.length, // 索引数量
gl.UNSIGNED_SHORT, // 索引类型
0 // 偏移
);4.6.3 索引数据类型
javascript
// 索引数据类型决定了最大顶点数量
// Uint8: 最多 256 个顶点
const indices8 = new Uint8Array([0, 1, 2]);
gl.drawElements(gl.TRIANGLES, 3, gl.UNSIGNED_BYTE, 0);
// Uint16: 最多 65536 个顶点(最常用)
const indices16 = new Uint16Array([0, 1, 2]);
gl.drawElements(gl.TRIANGLES, 3, gl.UNSIGNED_SHORT, 0);
// Uint32: 最多 42 亿个顶点(需要扩展或 WebGL 2.0)
// WebGL 1.0 需要: gl.getExtension('OES_element_index_uint')
const indices32 = new Uint32Array([0, 1, 2]);
gl.drawElements(gl.TRIANGLES, 3, gl.UNSIGNED_INT, 0);4.6.4 内存节省计算
javascript
// 假设绘制一个球体:
// - 分辨率:经度 36 段,纬度 18 段
// - 顶点数:36 * 18 = 648 个
// - 三角形数:36 * 17 * 2 = 1224 个
// 不使用索引:
// 1224 三角形 × 3 顶点/三角形 = 3672 个顶点数据
// 每个顶点 8 floats (位置 + 法线 + UV) = 32 字节
// 总计:3672 × 32 = 117,504 字节 ≈ 115 KB
// 使用索引:
// 648 个顶点 × 32 字节 = 20,736 字节
// 1224 三角形 × 3 索引 × 2 字节(Uint16) = 7,344 字节
// 总计:20,736 + 7,344 = 28,080 字节 ≈ 27 KB
// 节省了约 76%!4.7 顶点数组对象(VAO)
4.7.1 什么是 VAO?
VAO(Vertex Array Object) 封装了所有的顶点属性状态,让我们可以一次性恢复所有设置。
没有 VAO 时,每次绘制都需要:
function render() {
// 每次都要重新设置...
gl.bindBuffer(gl.ARRAY_BUFFER, posBuffer);
gl.enableVertexAttribArray(posLoc);
gl.vertexAttribPointer(posLoc, 3, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
gl.enableVertexAttribArray(colorLoc);
gl.vertexAttribPointer(colorLoc, 3, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
gl.drawElements(gl.TRIANGLES, count, gl.UNSIGNED_SHORT, 0);
}
有 VAO 时:
// 初始化时设置一次
gl.bindVertexArray(vao);
// ... 设置所有属性 ...
gl.bindVertexArray(null);
// 渲染时只需绑定 VAO
function render() {
gl.bindVertexArray(vao);
gl.drawElements(gl.TRIANGLES, count, gl.UNSIGNED_SHORT, 0);
}4.7.2 VAO 存储的状态
VAO 记录的状态:
┌────────────────────────────────────────────────────────┐
│ VAO 对象 │
├────────────────────────────────────────────────────────┤
│ │
│ ELEMENT_ARRAY_BUFFER 绑定 │
│ ┌─────────────────────────────────────┐ │
│ │ indexBuffer │ │
│ └─────────────────────────────────────┘ │
│ │
│ 顶点属性 0: │
│ ┌─────────────────────────────────────────────────┐ │
│ │ enabled: true │ │
│ │ buffer: positionBuffer │ │
│ │ size: 3, type: FLOAT, stride: 0, offset: 0 │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ 顶点属性 1: │
│ ┌─────────────────────────────────────────────────┐ │
│ │ enabled: true │ │
│ │ buffer: colorBuffer │ │
│ │ size: 3, type: FLOAT, stride: 0, offset: 0 │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ 顶点属性 2: │
│ ┌─────────────────────────────────────────────────┐ │
│ │ enabled: true │ │
│ │ buffer: texCoordBuffer │ │
│ │ size: 2, type: FLOAT, stride: 0, offset: 0 │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ ... 更多属性 ... │
│ │
└────────────────────────────────────────────────────────┘
注意:VAO 不存储 ARRAY_BUFFER 绑定!
每个属性单独记录它使用的缓冲区。4.7.3 VAO 使用示例
javascript
// ============ WebGL 2.0 原生 VAO ============
// 创建 VAO
const vao = gl.createVertexArray();
// 绑定 VAO(开始记录状态)
gl.bindVertexArray(vao);
// 设置顶点缓冲区和属性
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.enableVertexAttribArray(positionLoc);
gl.vertexAttribPointer(positionLoc, 3, gl.FLOAT, false, stride, 0);
gl.enableVertexAttribArray(colorLoc);
gl.vertexAttribPointer(colorLoc, 3, gl.FLOAT, false, stride, 12);
// 设置索引缓冲区
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
// 解绑 VAO(停止记录)
gl.bindVertexArray(null);
// 渲染时
function render() {
gl.useProgram(program);
gl.bindVertexArray(vao);
gl.drawElements(gl.TRIANGLES, indexCount, gl.UNSIGNED_SHORT, 0);
gl.bindVertexArray(null); // 可选
}
// ============ WebGL 1.0 使用扩展 ============
// 获取扩展
const ext = gl.getExtension('OES_vertex_array_object');
if (!ext) {
console.error('VAO 扩展不可用');
}
// 使用扩展的方法
const vao = ext.createVertexArrayOES();
ext.bindVertexArrayOES(vao);
// ... 设置属性 ...
ext.bindVertexArrayOES(null);
// 渲染时
ext.bindVertexArrayOES(vao);
gl.drawElements(gl.TRIANGLES, indexCount, gl.UNSIGNED_SHORT, 0);4.7.4 完整的 VAO 封装
javascript
/**
* VAO 封装类
*/
class VertexArrayObject {
constructor(gl) {
this.gl = gl;
this.vao = null;
this.ext = null;
this.isWebGL2 = gl instanceof WebGL2RenderingContext;
if (this.isWebGL2) {
this.vao = gl.createVertexArray();
} else {
this.ext = gl.getExtension('OES_vertex_array_object');
if (this.ext) {
this.vao = this.ext.createVertexArrayOES();
}
}
}
bind() {
if (this.isWebGL2) {
this.gl.bindVertexArray(this.vao);
} else if (this.ext) {
this.ext.bindVertexArrayOES(this.vao);
}
}
unbind() {
if (this.isWebGL2) {
this.gl.bindVertexArray(null);
} else if (this.ext) {
this.ext.bindVertexArrayOES(null);
}
}
dispose() {
if (this.isWebGL2) {
this.gl.deleteVertexArray(this.vao);
} else if (this.ext) {
this.ext.deleteVertexArrayOES(this.vao);
}
this.vao = null;
}
isSupported() {
return this.vao !== null;
}
}
// 使用
const vao = new VertexArrayObject(gl);
vao.bind();
// ... 设置属性 ...
vao.unbind();
// 渲染
vao.bind();
gl.drawElements(...);
vao.unbind();4.8 动态数据更新
4.8.1 更新策略
根据更新频率选择合适的策略:
javascript
// ============ 策略 1: 完全替换(适合偶尔更新)============
function updateAllData(newData) {
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, newData, gl.DYNAMIC_DRAW);
}
// ============ 策略 2: 部分更新(适合局部修改)============
function updatePartialData(offset, newData) {
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferSubData(gl.ARRAY_BUFFER, offset, newData);
}
// ============ 策略 3: 双缓冲(适合频繁更新)============
class DoubleBuffer {
constructor(gl, size, usage) {
this.gl = gl;
this.buffers = [
this.createBuffer(size, usage),
this.createBuffer(size, usage)
];
this.currentIndex = 0;
}
createBuffer(size, usage) {
const buffer = this.gl.createBuffer();
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, buffer);
this.gl.bufferData(this.gl.ARRAY_BUFFER, size, usage);
return buffer;
}
// 获取用于绘制的缓冲区
getReadBuffer() {
return this.buffers[this.currentIndex];
}
// 获取用于写入的缓冲区
getWriteBuffer() {
return this.buffers[1 - this.currentIndex];
}
// 交换缓冲区
swap() {
this.currentIndex = 1 - this.currentIndex;
}
}
// 使用双缓冲
const doubleBuffer = new DoubleBuffer(gl, dataSize, gl.STREAM_DRAW);
function update(newData) {
// 写入到"写缓冲区"
gl.bindBuffer(gl.ARRAY_BUFFER, doubleBuffer.getWriteBuffer());
gl.bufferSubData(gl.ARRAY_BUFFER, 0, newData);
// 交换
doubleBuffer.swap();
}
function render() {
// 从"读缓冲区"绘制
gl.bindBuffer(gl.ARRAY_BUFFER, doubleBuffer.getReadBuffer());
// ...绑制...
}4.8.2 实际应用:粒子系统
javascript
/**
* 粒子系统示例
*/
class ParticleSystem {
constructor(gl, maxParticles) {
this.gl = gl;
this.maxParticles = maxParticles;
this.activeCount = 0;
// 粒子数据:位置(3) + 速度(3) + 颜色(4) + 生命周期(1) = 11 floats
this.floatsPerParticle = 11;
this.data = new Float32Array(maxParticles * this.floatsPerParticle);
// 创建缓冲区
this.buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
gl.bufferData(gl.ARRAY_BUFFER, this.data, gl.STREAM_DRAW);
}
emit(x, y, z, count) {
for (let i = 0; i < count && this.activeCount < this.maxParticles; i++) {
const offset = this.activeCount * this.floatsPerParticle;
// 位置
this.data[offset + 0] = x;
this.data[offset + 1] = y;
this.data[offset + 2] = z;
// 随机速度
this.data[offset + 3] = (Math.random() - 0.5) * 2;
this.data[offset + 4] = Math.random() * 2;
this.data[offset + 5] = (Math.random() - 0.5) * 2;
// 颜色
this.data[offset + 6] = 1.0;
this.data[offset + 7] = Math.random();
this.data[offset + 8] = 0.0;
this.data[offset + 9] = 1.0;
// 生命周期
this.data[offset + 10] = 1.0 + Math.random();
this.activeCount++;
}
}
update(deltaTime) {
let writeIndex = 0;
for (let i = 0; i < this.activeCount; i++) {
const readOffset = i * this.floatsPerParticle;
const writeOffset = writeIndex * this.floatsPerParticle;
// 读取数据
let life = this.data[readOffset + 10];
life -= deltaTime;
// 如果粒子还活着
if (life > 0) {
// 更新位置
const vx = this.data[readOffset + 3];
const vy = this.data[readOffset + 4] - 9.8 * deltaTime; // 重力
const vz = this.data[readOffset + 5];
this.data[writeOffset + 0] = this.data[readOffset + 0] + vx * deltaTime;
this.data[writeOffset + 1] = this.data[readOffset + 1] + vy * deltaTime;
this.data[writeOffset + 2] = this.data[readOffset + 2] + vz * deltaTime;
// 更新速度
this.data[writeOffset + 3] = vx;
this.data[writeOffset + 4] = vy;
this.data[writeOffset + 5] = vz;
// 复制颜色
this.data[writeOffset + 6] = this.data[readOffset + 6];
this.data[writeOffset + 7] = this.data[readOffset + 7];
this.data[writeOffset + 8] = this.data[readOffset + 8];
this.data[writeOffset + 9] = life; // Alpha 随生命周期衰减
// 更新生命周期
this.data[writeOffset + 10] = life;
writeIndex++;
}
}
this.activeCount = writeIndex;
// 上传更新后的数据
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.buffer);
this.gl.bufferSubData(this.gl.ARRAY_BUFFER, 0,
this.data.subarray(0, this.activeCount * this.floatsPerParticle));
}
render(program) {
const gl = this.gl;
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
const FSIZE = 4;
const stride = this.floatsPerParticle * FSIZE;
// 设置属性
const posLoc = gl.getAttribLocation(program, 'a_position');
gl.enableVertexAttribArray(posLoc);
gl.vertexAttribPointer(posLoc, 3, gl.FLOAT, false, stride, 0);
const colorLoc = gl.getAttribLocation(program, 'a_color');
gl.enableVertexAttribArray(colorLoc);
gl.vertexAttribPointer(colorLoc, 4, gl.FLOAT, false, stride, 6 * FSIZE);
// 绘制点
gl.drawArrays(gl.POINTS, 0, this.activeCount);
}
}4.9 本章小结
核心概念
| 概念 | 说明 |
|---|---|
| 缓冲区 | GPU 显存中存储数据的区域 |
| 类型化数组 | JavaScript 与 GPU 数据交换的格式 |
| vertexAttribPointer | 定义如何从缓冲区读取顶点数据 |
| stride/offset | 控制数据布局的关键参数 |
| 索引缓冲 | 顶点复用,节省内存 |
| VAO | 封装顶点属性状态 |
关键 API
| API | 作用 |
|---|---|
createBuffer() | 创建缓冲区 |
bindBuffer() | 绑定缓冲区 |
bufferData() | 上传数据 |
bufferSubData() | 更新部分数据 |
vertexAttribPointer() | 配置属性指针 |
enableVertexAttribArray() | 启用属性 |
drawArrays() | 非索引绘制 |
drawElements() | 索引绘制 |
createVertexArray() | 创建 VAO (WebGL 2) |
数据布局对比
| 布局方式 | 优点 | 适用场景 |
|---|---|---|
| 交错 | 缓存友好 | 静态模型 |
| 分离 | 更新灵活 | 动态数据 |
4.10 练习题
基础练习
创建一个使用索引缓冲的立方体
比较交错布局和分离布局的内存使用
使用 VAO 简化渲染代码
进阶练习
实现一个动态更新顶点位置的波浪效果
创建一个简单的粒子系统
挑战练习
- 实现一个支持 LOD(细节层次)的地形渲染系统
下一章预告:在第5章中,我们将学习纹理系统,为模型添加丰富的细节。
文档版本:v1.0
字数统计:约 14,000 字
代码示例:40+ 个
