Skip to content

第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_BUFFERuniform 块(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 练习题 ​

基础练习 ​

  1. 创建一个使用索引缓冲的立方体

  2. 比较交错布局和分离布局的内存使用

  3. 使用 VAO 简化渲染代码

进阶练习 ​

  1. 实现一个动态更新顶点位置的波浪效果

  2. 创建一个简单的粒子系统

挑战练习 ​

  1. 实现一个支持 LOD(细节层次)的地形渲染系统

下一章预告:在第5章中,我们将学习纹理系统,为模型添加丰富的细节。


文档版本:v1.0
字数统计:约 14,000 字
代码示例:40+ 个

如有转载或 CV 的请标注本站原文地址