第 2 节:第一个三角形(顶点数据与缓冲区)
🎯 学习目标
- 理解顶点缓冲区的创建和使用
- 掌握
vertexAttribPointer的配置 - 理解
drawArrays的调用方式 - 对比原生 WebGL 和 Three.js 的几何体管理
📖 理论:顶点缓冲区(Vertex Buffer)
在 WebGL 中,所有顶点数据都需要存储在 GPU 的缓冲区(Buffer)中。这个过程包括:
- 创建缓冲区:
gl.createBuffer() - 绑定缓冲区:
gl.bindBuffer(gl.ARRAY_BUFFER, buffer) - 上传数据:
gl.bufferData(gl.ARRAY_BUFFER, data, usage) - 配置属性:
gl.vertexAttribPointer() - 启用属性:
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>
代码解析
- 交错存储(Interleaved):位置和颜色数据交错存储在一个数组中
- Stride:
6 * 4表示每个顶点占用 24 字节(6 个 float × 4 字节) - 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 的封装对应关系
| 原生 WebGL | Three.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
- 更安全,但灵活性稍低
📝 编程作业
基础作业(⭐)
任务:绘制一个彩色三角形
要求:
- 使用原生 WebGL 实现
- 使用 Three.js 实现
- 三个顶点使用不同颜色(红、绿、蓝)
- 观察颜色在三角形内部的插值效果
检查清单:
- 原生 WebGL 版本可以正常显示三角形
- Three.js 版本可以正常显示三角形
- 理解
vertexAttribPointer的参数含义 - 理解
varying变量的插值机制
进阶作业(⭐⭐)
任务:绘制多个三角形
要求:
- 绘制 4 个三角形,组成一个正方形
- 每个三角形使用不同的颜色
- 使用两种方式实现:
- 方式 1:4 次
drawArrays调用 - 方式 2:1 次
drawArrays调用(所有顶点在一个 Buffer 中)
- 方式 1:4 次
提示:
// 方式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())
挑战作业(⭐⭐⭐)
任务:实现一个几何体编辑器
要求:
- 可以动态添加/删除顶点
- 可以修改顶点的位置和颜色
- 实时显示三角形
- 显示顶点索引和坐标信息
扩展功能:
- 支持绘制多个独立的三角形
- 支持切换填充/线框模式
- 支持导出几何体数据
检查清单:
- 可以动态修改顶点
- 理解 Buffer 的动态更新(
gl.bufferSubData) - 理解
gl.DYNAMIC_DRAW的使用场景
🎓 本节总结
关键概念
- 顶点缓冲区:存储顶点数据的 GPU 内存区域
- vertexAttribPointer:告诉 GPU 如何读取缓冲区数据
- Stride 和 Offset:控制数据在缓冲区中的布局
- Draw Call:每次
drawArrays调用都是一次 Draw Call
原生 WebGL vs Three.js
| 方面 | 原生 WebGL | Three.js |
|---|---|---|
| 缓冲区管理 | 手动创建、绑定、上传 | 自动管理 |
| 属性配置 | 手动配置 stride/offset | 自动计算 |
| 代码量 | ~100 行 | ~30 行 |
| 灵活性 | 完全控制 | 受限于 API |
| 错误处理 | 需要手动检查 | 自动验证 |
性能提示
- 减少 Draw Call:尽量合并多个三角形到一次绘制
- 使用合适的 usage:
STATIC_DRAW用于静态几何体 - 交错 vs 分离:交错存储可能更快(缓存友好),但分离存储更灵活
🔍 深入理解:drawArrays vs drawElements
两种绘制方法的本质区别
| 特性 | gl.drawArrays | gl.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 节:着色器编译与程序链接