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 的请标注本站原文地址