Skip to main content

第 2 节:第一个三角形(顶点数据与缓冲区)

🎯 学习目标

  • 理解顶点缓冲区的创建和使用
  • 掌握 vertexAttribPointer 的配置
  • 理解 drawArrays 的调用方式
  • 对比原生 WebGL 和 Three.js 的几何体管理

📖 理论:顶点缓冲区(Vertex Buffer)

在 WebGL 中,所有顶点数据都需要存储在 GPU 的缓冲区(Buffer)中。这个过程包括:

  1. 创建缓冲区gl.createBuffer()
  2. 绑定缓冲区gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
  3. 上传数据gl.bufferData(gl.ARRAY_BUFFER, data, usage)
  4. 配置属性gl.vertexAttribPointer()
  5. 启用属性gl.enableVertexAttribArray()

缓冲区使用模式

gl.STATIC_DRAW; // 数据不会改变,适合几何体
gl.DYNAMIC_DRAW; // 数据经常改变,适合动画
gl.STREAM_DRAW; // 数据每帧都改变,适合粒子系统

vertexAttribPointer 参数详解

gl.vertexAttribPointer(
index, // 属性位置(从 getAttribLocation 获取)
size, // 每个顶点有几个分量(1-4)
type, // 数据类型(gl.FLOAT, gl.UNSIGNED_BYTE 等)
normalized, // 是否归一化(通常 false)
stride, // 步长(0 表示紧密排列)
offset // 偏移量(从缓冲区开始的位置)
);

💻 原生 WebGL 实现

让我们绘制一个彩色三角形:

<!DOCTYPE html>
<html>
<head>
<title>原生 WebGL 三角形</title>
<style>
body {
margin: 0;
}
canvas {
display: block;
}
</style>
</head>
<body>
<canvas id="glcanvas"></canvas>

<script>
// 1. 获取上下文
const canvas = document.getElementById("glcanvas");
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

const gl = canvas.getContext("webgl");
if (!gl) {
alert("无法初始化 WebGL");
}

// 2. 顶点着色器
const vertexShaderSource = `
attribute vec4 aVertexPosition;
attribute vec4 aVertexColor;

varying vec4 vColor; // 传递给片段着色器

void main() {
gl_Position = aVertexPosition;
vColor = aVertexColor;
}
`;

// 3. 片段着色器
const fragmentShaderSource = `
precision mediump float;

varying vec4 vColor;

void main() {
gl_FragColor = vColor;
}
`;

// 4. 编译着色器(复用上一节的函数)
function createShader(gl, type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);

if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error("着色器编译错误:", gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}

function createProgram(gl, vertexShader, fragmentShader) {
const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);

if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error("程序链接错误:", gl.getProgramInfoLog(program));
gl.deleteProgram(program);
return null;
}
return program;
}

const vertexShader = createShader(
gl,
gl.VERTEX_SHADER,
vertexShaderSource
);
const fragmentShader = createShader(
gl,
gl.FRAGMENT_SHADER,
fragmentShaderSource
);
const program = createProgram(gl, vertexShader, fragmentShader);
gl.useProgram(program);

// 5. 准备顶点数据
// 三个顶点,每个顶点包含位置(3个float)和颜色(3个float)
// 方式1:交错存储(Interleaved)
const vertices = new Float32Array([
// 顶点1: 位置(x,y,z) + 颜色(r,g,b)
-0.5,
-0.5,
0.0,
1.0,
0.0,
0.0, // 左下,红色
0.5,
-0.5,
0.0,
0.0,
1.0,
0.0, // 右下,绿色
0.0,
0.5,
0.0,
0.0,
0.0,
1.0, // 顶部,蓝色
]);

// 6. 创建位置缓冲区
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);

// 7. 配置位置属性
const positionAttributeLocation = gl.getAttribLocation(
program,
"aVertexPosition"
);
gl.enableVertexAttribArray(positionAttributeLocation);
gl.vertexAttribPointer(
positionAttributeLocation,
3, // 每个顶点有 3 个分量 (x, y, z)
gl.FLOAT,
false,
6 * 4, // stride: 每个顶点 6 个 float,每个 float 4 字节
0 // offset: 从位置 0 开始
);

// 8. 配置颜色属性
const colorAttributeLocation = gl.getAttribLocation(
program,
"aVertexColor"
);
gl.enableVertexAttribArray(colorAttributeLocation);
gl.vertexAttribPointer(
colorAttributeLocation,
3, // 每个顶点有 3 个分量 (r, g, b)
gl.FLOAT,
false,
6 * 4, // stride: 每个顶点 6 个 float
3 * 4 // offset: 跳过前 3 个 float(位置)
);

// 9. 设置视口和清空画布
gl.viewport(0, 0, canvas.width, canvas.height);
gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);

// 10. 绘制三角形
gl.drawArrays(gl.TRIANGLES, 0, 3); // 绘制 3 个顶点,组成 1 个三角形
</script>
</body>
</html>

代码解析

  1. 交错存储(Interleaved):位置和颜色数据交错存储在一个数组中
  2. Stride6 * 4 表示每个顶点占用 24 字节(6 个 float × 4 字节)
  3. Offset:颜色属性从第 12 字节开始(跳过 3 个位置 float)

方式 2:分离存储(Separate Buffers)

也可以将位置和颜色分别存储在两个缓冲区中:

// 位置数据
const positions = new Float32Array([
-0.5, -0.5, 0.0, 0.5, -0.5, 0.0, 0.0, 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 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.STATIC_DRAW);

// 配置属性时,需要先绑定对应的缓冲区
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.vertexAttribPointer(positionAttributeLocation, 3, gl.FLOAT, false, 0, 0);

gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
gl.vertexAttribPointer(colorAttributeLocation, 3, gl.FLOAT, false, 0, 0);

🎨 Three.js 对比实现

Three.js 使用 BufferGeometry 来管理顶点数据:

<!DOCTYPE html>
<html>
<head>
<title>Three.js 三角形</title>
<style>
body {
margin: 0;
}
canvas {
display: block;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>

<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js"
}
}
</script>

<script type="module">
import * as THREE from "three";

// 1. 创建场景
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x000000);

// 2. 创建相机
const camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
);
camera.position.z = 5;

// 3. 创建渲染器
const renderer = new THREE.WebGLRenderer({
canvas: document.getElementById("canvas"),
});
renderer.setSize(window.innerWidth, window.innerHeight);

// 4. 创建几何体(封装了 Buffer 管理)
const geometry = new THREE.BufferGeometry();

// 设置位置属性(对应原生 WebGL 的 positionBuffer)
const positions = new Float32Array([
-0.5, -0.5, 0.0, 0.5, -0.5, 0.0, 0.0, 0.5, 0.0,
]);
geometry.setAttribute(
"position",
new THREE.BufferAttribute(positions, 3)
);

// 设置颜色属性(对应原生 WebGL 的 colorBuffer)
const colors = new Float32Array([
1.0,
0.0,
0.0, // 红色
0.0,
1.0,
0.0, // 绿色
0.0,
0.0,
1.0, // 蓝色
]);
geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));

// 5. 创建材质(自动处理 Shader)
const material = new THREE.MeshBasicMaterial({
vertexColors: true, // 启用顶点颜色
});

// 6. 创建网格
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);

// 7. 渲染
renderer.render(scene, camera);
</script>
</body>
</html>

Three.js 的封装对应关系

原生 WebGLThree.js说明
gl.createBuffer()new BufferAttribute()自动创建缓冲区
gl.bindBuffer()setAttribute()自动绑定缓冲区
gl.bufferData()setAttribute()自动上传数据
gl.vertexAttribPointer()setAttribute()自动配置属性
gl.enableVertexAttribArray()setAttribute()自动启用属性
gl.drawArrays()render()自动调用绘制

🔍 原理对比分析

1. 缓冲区管理

原生 WebGL

  • 需要手动创建、绑定、上传每个缓冲区
  • 需要手动管理内存
  • 需要手动配置 stride 和 offset

Three.js

  • BufferGeometry 自动管理所有缓冲区
  • 自动计算 stride 和 offset
  • 自动处理数据类型转换

2. 属性配置

原生 WebGL

// 需要手动获取属性位置
const positionLocation = gl.getAttribLocation(program, "aVertexPosition");
// 需要手动启用和配置
gl.enableVertexAttribArray(positionLocation);
gl.vertexAttribPointer(positionLocation, 3, gl.FLOAT, false, 0, 0);

Three.js

// 自动处理所有配置
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
// Three.js 会自动:
// 1. 创建缓冲区
// 2. 上传数据
// 3. 在 Shader 中查找对应的 attribute
// 4. 配置 vertexAttribPointer

3. 数据组织方式

原生 WebGL

  • 支持交错存储和分离存储
  • 需要手动计算 stride 和 offset
  • 更灵活,但容易出错

Three.js

  • 默认使用分离存储(每个属性一个 Buffer)
  • 自动处理 stride 和 offset
  • 更安全,但灵活性稍低

📝 编程作业

基础作业(⭐)

任务:绘制一个彩色三角形

要求

  1. 使用原生 WebGL 实现
  2. 使用 Three.js 实现
  3. 三个顶点使用不同颜色(红、绿、蓝)
  4. 观察颜色在三角形内部的插值效果

检查清单

  • 原生 WebGL 版本可以正常显示三角形
  • Three.js 版本可以正常显示三角形
  • 理解 vertexAttribPointer 的参数含义
  • 理解 varying 变量的插值机制

进阶作业(⭐⭐)

任务:绘制多个三角形

要求

  1. 绘制 4 个三角形,组成一个正方形
  2. 每个三角形使用不同的颜色
  3. 使用两种方式实现:
    • 方式 1:4 次 drawArrays 调用
    • 方式 2:1 次 drawArrays 调用(所有顶点在一个 Buffer 中)

提示

// 方式1:多次绘制
for (let i = 0; i < 4; i++) {
// 更新缓冲区数据
// gl.drawArrays(gl.TRIANGLES, i * 3, 3);
}

// 方式2:一次绘制
// 将所有 12 个顶点放在一个数组中
// gl.drawArrays(gl.TRIANGLES, 0, 12);

检查清单

  • 4 个三角形都正确显示
  • 理解 Draw Call 的概念
  • 对比两种方式的性能差异(使用 performance.now()

挑战作业(⭐⭐⭐)

任务:实现一个几何体编辑器

要求

  1. 可以动态添加/删除顶点
  2. 可以修改顶点的位置和颜色
  3. 实时显示三角形
  4. 显示顶点索引和坐标信息

扩展功能

  • 支持绘制多个独立的三角形
  • 支持切换填充/线框模式
  • 支持导出几何体数据

检查清单

  • 可以动态修改顶点
  • 理解 Buffer 的动态更新(gl.bufferSubData
  • 理解 gl.DYNAMIC_DRAW 的使用场景

🎓 本节总结

关键概念

  1. 顶点缓冲区:存储顶点数据的 GPU 内存区域
  2. vertexAttribPointer:告诉 GPU 如何读取缓冲区数据
  3. Stride 和 Offset:控制数据在缓冲区中的布局
  4. Draw Call:每次 drawArrays 调用都是一次 Draw Call

原生 WebGL vs Three.js

方面原生 WebGLThree.js
缓冲区管理手动创建、绑定、上传自动管理
属性配置手动配置 stride/offset自动计算
代码量~100 行~30 行
灵活性完全控制受限于 API
错误处理需要手动检查自动验证

性能提示

  1. 减少 Draw Call:尽量合并多个三角形到一次绘制
  2. 使用合适的 usageSTATIC_DRAW 用于静态几何体
  3. 交错 vs 分离:交错存储可能更快(缓存友好),但分离存储更灵活

🔍 深入理解:drawArrays vs drawElements

两种绘制方法的本质区别

特性gl.drawArraysgl.drawElements
数据源直接读取顶点缓冲区 (Array Buffer)先读索引缓冲区 (Index Buffer),再查顶点缓冲区
顶点复用无法复用。如果一个点被多个三角形共用,必须重复存多次可以复用。数据只存一次,通过索引多次引用
内存占用高(因为有重复数据)低(顶点去重了,索引占用的空间很小)
典型用途简单的点、线,或者没有共享顶点的图形复杂的网格(Mesh),如立方体、球体、3D 模型

直观对比:绘制一个立方体

使用 drawArrays(笨办法)

立方体有 6 个面,每个面 2 个三角形,共 12 个三角形。 每个三角形有 3 个顶点,所以总共需要绘制 36 个顶点。 即使一个角点是多个面的公共点,你也要重复存储多次:

// 立方体的 8 个角点(实际只需要这 8 个)
const corners = [
[-0.5, -0.5, 0.5], // 索引 0
[ 0.5, -0.5, 0.5], // 索引 1
[ 0.5, 0.5, 0.5], // 索引 2
[-0.5, 0.5, 0.5], // 索引 3
[-0.5, -0.5, -0.5], // 索引 4
[ 0.5, -0.5, -0.5], // 索引 5
[ 0.5, 0.5, -0.5], // 索引 6
[-0.5, 0.5, -0.5], // 索引 7
];

// 但用 drawArrays,你必须把每个三角形都完整写出来
const vertices = [
// 前面三角形 1
-0.5, -0.5, 0.5, // 角点 0
0.5, -0.5, 0.5, // 角点 1
0.5, 0.5, 0.5, // 角点 2
// 前面三角形 2
-0.5, -0.5, 0.5, // ⚠️ 角点 0 重复了!
0.5, 0.5, 0.5, // ⚠️ 角点 2 重复了!
-0.5, 0.5, 0.5, // 角点 3
// ... 后面还有 30 个点(很多重复)
];

gl.drawArrays(gl.TRIANGLES, 0, 36); // 绘制 36 个顶点

问题:内存浪费严重,一个角点可能被存储 3-6 次。

使用 drawElements(推荐方法)

只需要存储 8 个唯一的角点,然后用索引数组告诉 GPU 如何连线:

// 1. 顶点数据只存 8 个(去重)
const vertices = new Float32Array([
-0.5, -0.5, 0.5, // 索引 0
0.5, -0.5, 0.5, // 索引 1
0.5, 0.5, 0.5, // 索引 2
-0.5, 0.5, 0.5, // 索引 3
-0.5, -0.5, -0.5, // 索引 4
0.5, -0.5, -0.5, // 索引 5
0.5, 0.5, -0.5, // 索引 6
-0.5, 0.5, -0.5, // 索引 7
]);

// 2. 索引数组告诉 GPU 怎么连线(复用索引)
const indices = new Uint16Array([
// 前面
0, 1, 2, 0, 2, 3, // 复用了索引 0 和 2
// 后面
4, 7, 6, 4, 6, 5,
// 下面
0, 4, 5, 0, 5, 1, // 复用了索引 0
// 上面
2, 6, 7, 2, 7, 3, // 复用了索引 2
// 左面
0, 3, 7, 0, 7, 4, // 复用了索引 0
// 右面
1, 5, 6, 1, 6, 2, // 复用了索引 1 和 2
]);

// 3. 创建索引缓冲区
const indexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);

// 4. 绘制(注意:需要绑定 ELEMENT_ARRAY_BUFFER)
gl.drawElements(gl.TRIANGLES, 36, gl.UNSIGNED_SHORT, 0);

优势

  • ✅ 内存节省:8 个顶点 vs 36 个顶点(节省 78% 内存)
  • ✅ GPU 带宽节省:传输的数据量大幅减少
  • ✅ 性能提升:特别是在复杂模型中,效果更明显

记忆口诀

  • drawArrays:就像老师点名,不管那个人是不是已经点过了,念到一个名字就站起来一个人。(不仅浪费口舌,还浪费空间)
  • drawElements:就像老师发了一个名单编号,说"请编号 1, 2, 3 的同学上来,然后编号 1, 3, 4 的同学上来"。(人只需要在教室里坐一份,被叫到多次即可)

什么时候用哪个?

场景推荐方法原因
绘制点、线drawArrays点线通常没有共享顶点
绘制单个三角形drawArrays简单场景,3 个顶点没有重复
绘制立方体、球体drawElements大量共享顶点,节省内存
绘制 3D 模型drawElements模型网格必然有大量共享顶点
粒子系统drawArrays每个粒子独立,没有共享

下一步

完成作业后,进入下一节:第 3 节:着色器编译与程序链接


📚 参考资源