09. 进阶实战:纯原生 WebGL (No Three.js)
虽然 Three.js 极大地简化了开发,但为了彻底掌握底层原理,没有任何方法比亲手写一遍纯原生 WebGL API 更好的了。
这就像是学会了开车(Three.js),现在我们要打开引擎盖看看发动机(WebGL)。
目标
脱离 Three.js,直接调用 WebGL API 绘制一个彩色的三角形。
核心流程
原生 WebGL 的步骤非常繁琐,但逻辑很清晰:
- 准备画布:获取
gl上下文。 - 创建着色器:编译 GLSL 代码。
- 创建程序:链接顶点和片段着色器。
- 准备数据:把顶点坐标塞进缓冲区 (Buffer)。
- 配置属性:告诉 GPU 怎么读取缓冲区的数据。
- 绘制:调用
gl.drawArrays。
代码 (index.html)
你可以直接保存为 HTML 文件并在浏览器打开。
<!DOCTYPE html>
<html>
<head>
<title>原生 WebGL 三角形</title>
<style>
body {
margin: 0;
overflow: hidden;
}
canvas {
width: 100vw;
height: 100vh;
display: block;
}
</style>
</head>
<body>
<canvas id="glcanvas"></canvas>
<script>
// 1. 获取 WebGL 上下文
const canvas = document.getElementById("glcanvas");
// 设置画布实际像素大小
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const gl = canvas.getContext("webgl");
if (!gl) {
alert("无法初始化 WebGL");
}
// --- 2. 编写 Shader 源码 (字符串) ---
// 顶点着色器:直接输出位置,不做矩阵变换
const vsSource = `
attribute vec4 aVertexPosition;
attribute vec4 aVertexColor;
varying lowp vec4 vColor;
void main() {
gl_Position = aVertexPosition;
vColor = aVertexColor; // 将颜色传给片段着色器
}
`;
// 片段着色器:直接输出颜色
const fsSource = `
varying lowp vec4 vColor;
void main() {
gl_FragColor = vColor;
}
`;
// --- 3. 编译着色器的辅助函数 ---
function loadShader(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;
}
// 编译顶点和片段着色器
const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vsSource);
const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fsSource);
// --- 4. 创建着色器程序 (Program) ---
const shaderProgram = gl.createProgram();
gl.attachShader(shaderProgram, vertexShader);
gl.attachShader(shaderProgram, fragmentShader);
gl.linkProgram(shaderProgram);
if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) {
console.error("链接错误: " + gl.getProgramInfoLog(shaderProgram));
}
// --- 5. 准备数据 (顶点和颜色) ---
// 数据:X, Y (位置), R, G, B (颜色)
// 一个三角形,3个顶点
const vertices = new Float32Array([
0.0,
0.5,
1.0,
0.0,
0.0, // 顶点1: 上方, 红色
-0.5,
-0.5,
0.0,
1.0,
0.0, // 顶点2: 左下, 绿色
0.5,
-0.5,
0.0,
0.0,
1.0, // 顶点3: 右下, 蓝色
]);
// 创建缓冲区
const vertexBuffer = gl.createBuffer();
// 绑定缓冲区:告诉 WebGL "接下来的操作是针对这个 Buffer 的"
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
// 把数据塞进去
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
// --- 6. 告诉 GPU 如何解析数据 ---
// 获取属性的位置 (Pointer)
const positionLoc = gl.getAttribLocation(
shaderProgram,
"aVertexPosition"
);
const colorLoc = gl.getAttribLocation(shaderProgram, "aVertexColor");
// 启用属性
gl.enableVertexAttribArray(positionLoc);
gl.enableVertexAttribArray(colorLoc);
// 解释数据结构:
// 每个元素是 float (4字节)
// 一行数据(stride)总共是 5个float = 20字节
const STRIDE = 5 * 4;
// 配置 aVertexPosition
gl.vertexAttribPointer(
positionLoc,
2, // size: 每次读取 2 个 float (x, y)
gl.FLOAT, // type: 数据类型
false, // normalize: 不归一化
STRIDE, // stride: 步长 (跳过多少字节读取下一个顶点的同样属性)
0 // offset: 偏移量 (从第0个字节开始)
);
// 配置 aVertexColor
gl.vertexAttribPointer(
colorLoc,
3, // size: 每次读取 3 个 float (r, g, b)
gl.FLOAT,
false,
STRIDE,
2 * 4 // offset: 偏移量 (前两个是位置,所以跳过 2*4 = 8 字节)
);
// --- 7. 绘制 ---
// 清空屏幕
gl.clearColor(0.0, 0.0, 0.0, 1.0); // 黑色背景
gl.clear(gl.COLOR_BUFFER_BIT);
// 使用程序
gl.useProgram(shaderProgram);
// 画出来!
// gl.TRIANGLES: 每3个点画一个三角形
// 0: 从第0个点开始
// 3: 画3个点
gl.drawArrays(gl.TRIANGLES, 0, 3);
</script>
</body>
</html>
关键点解析
- Context (上下文):
canvas.getContext("webgl")是所有操作的起点。 - Shader 编译:在 Three.js 中这是自动的,但在原生 WebGL 中,你需要手动调用
compileShader和linkProgram。 - Buffer (缓冲区):GPU 不能直接读取 JS 数组。必须先创建 Buffer,绑定它,然后用
gl.bufferData把数据“推”过去。 - VertexAttribPointer:这是最难理解也是最重要的一步。因为我们把位置和颜色混在一个数组里了(交错数组),所以必须告诉 GPU:
- “每隔 20 个字节读一次。” (Stride)
- “位置从第 0 个字节开始读 2 个数。” (Offset 0)
- “颜色从第 8 个字节开始读 3 个数。” (Offset 8)
为什么 Three.js 伟大?
看完这个例子,你应该能体会到。仅仅是为了画一个不动的三角形,我们就写了近 100 行代码,涉及了极其繁琐的 Buffer 管理和内存偏移量计算。
Three.js 的 BufferGeometry 和 Material 实际上就是帮你把这 100 行代码封装起来了。
Three.js 的封装与优化:从繁琐到优雅
通过上面的原生 WebGL 示例,我们已经深刻体会到了底层 API 的复杂性。现在让我们深入理解 Three.js 是如何将这些繁琐操作封装起来,并在性能上做了哪些优化。
1. 封装层面的简化
1.1 Buffer 管理自动化
原生 WebGL:
// 需要手动创建、绑定、上传数据
const vertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
// 需要手动计算 stride 和 offset
const STRIDE = 5 * 4;
gl.vertexAttribPointer(positionLoc, 2, gl.FLOAT, false, STRIDE, 0);
gl.vertexAttribPointer(colorLoc, 3, gl.FLOAT, false, STRIDE, 2 * 4);
Three.js:
// 一行代码搞定,自动处理所有细节
const geometry = new THREE.BufferGeometry();
geometry.setAttribute(
"position",
new THREE.Float32BufferAttribute(positions, 3)
);
geometry.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3));
源码大致如下:
// === 对应 setAttribute("position", ...) ===
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);
const positionLoc = gl.getAttribLocation(shaderProgram, "position");
gl.enableVertexAttribArray(positionLoc);
gl.vertexAttribPointer(
positionLoc,
3, // size: x, y, z
gl.FLOAT,
false,
0, // stride: 0 (分离数组,连续读取)
0 // offset: 0
);
// === 对应 setAttribute("color", ...) ===
const colorBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
gl.bufferData(gl.ARRAY_BUFFER, colors, gl.STATIC_DRAW);
const colorLoc = gl.getAttribLocation(shaderProgram, "color");
gl.enableVertexAttribArray(colorLoc);
gl.vertexAttribPointer(
colorLoc,
3, // size: r, g, b
gl.FLOAT,
false,
0, // stride: 0 (分离数组)
0 // offset: 0
);
封装优势:
- 自动管理 Buffer 的生命周期(创建、更新、销毁)
- 自动计算 stride 和 offset
- 支持多种数据类型(Float32、Uint16、Int16 等)
- 自动处理交错数组(Interleaved Buffer)
1.2 Shader 编译自动化
原生 WebGL:
// 需要手动编译、链接、错误检查
function loadShader(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;
}
const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vsSource);
const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fsSource);
const shaderProgram = gl.createProgram();
gl.attachShader(shaderProgram, vertexShader);
gl.attachShader(shaderProgram, fragmentShader);
gl.linkProgram(shaderProgram);
Three.js:
// 自动编译、链接、错误处理
const material = new THREE.MeshBasicMaterial({ color: 0xff0000 });
// 或者自定义 Shader
const material = new THREE.ShaderMaterial({
vertexShader: vsSource,
fragmentShader: fsSource,
});
封装优势:
- 自动编译和链接 Shader
- 统一的错误处理机制
- 自动注入常用 uniform(如
projectionMatrix、viewMatrix、modelMatrix) - 支持 ShaderChunk 模块化系统(见第 13 节)
1.3 状态管理自动化
原生 WebGL:
// 需要手动管理所有状态
gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(shaderProgram);
gl.enableVertexAttribArray(positionLoc);
gl.enableVertexAttribArray(colorLoc);
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.drawArrays(gl.TRIANGLES, 0, 3);
Three.js:
// 自动管理所有状态
renderer.render(scene, camera);
封装优势:
- 自动管理 WebGL 状态机(深度测试、混合、面剔除等)
- 自动优化状态切换(避免不必要的状态变更)
- 自动处理视口、清除颜色等设置
1.4 矩阵计算自动化
原生 WebGL:
// 需要手动计算 MVP 矩阵
const modelMatrix = mat4.create();
const viewMatrix = mat4.create();
const projectionMatrix = mat4.create();
mat4.translate(modelMatrix, modelMatrix, [x, y, z]);
mat4.rotate(modelMatrix, modelMatrix, angle, [0, 1, 0]);
mat4.scale(modelMatrix, modelMatrix, [sx, sy, sz]);
mat4.lookAt(
viewMatrix,
[eyeX, eyeY, eyeZ],
[centerX, centerY, centerZ],
[0, 1, 0]
);
mat4.perspective(projectionMatrix, fov, aspect, near, far);
// 手动传递到 Shader
gl.uniformMatrix4fv(modelLoc, false, modelMatrix);
gl.uniformMatrix4fv(viewLoc, false, viewMatrix);
gl.uniformMatrix4fv(projectionLoc, false, projectionMatrix);
Three.js:
// 自动计算和传递矩阵
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(x, y, z);
mesh.rotation.y = angle;
mesh.scale.set(sx, sy, sz);
const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
camera.position.set(eyeX, eyeY, eyeZ);
camera.lookAt(centerX, centerY, centerZ);
封装优势:
- 自动计算模型矩阵(位置、旋转、缩放)
- 自动计算视图矩阵和投影矩阵
- 自动计算法线矩阵(
normalMatrix) - 自动处理矩阵的更新时机(只在需要时更新)
2. 性能优化层面
Three.js 不仅在封装上简化了开发,更在性能上做了大量优化。这些优化对于大型 3D 应用至关重要。
2.1 状态缓存与批处理
问题:原生 WebGL 中,每次绘制都需要切换状态(Shader、Buffer、纹理等),这些切换操作非常昂贵。
Three.js 优化:
- 状态缓存:记录当前 WebGL 状态,只在状态改变时才调用 API
- 批处理(Batching):将相同材质的多个对象合并为一次 Draw Call
- 材质排序:按材质对对象排序,减少状态切换次数
性能提升:
// 原生 WebGL:100 个相同材质的对象 = 100 次 Draw Call
for (let i = 0; i < 100; i++) {
gl.useProgram(shaderProgram);
gl.bindBuffer(gl.ARRAY_BUFFER, buffers[i]);
gl.drawArrays(gl.TRIANGLES, 0, vertexCount);
}
// Three.js:自动批处理,可能只需要 1-10 次 Draw Call
// 通过 BufferGeometry.mergeGeometries() 或 BatchedMesh
Three.js mergeGeometries() 的实现原理(简化版):
// Three.js BufferGeometry.mergeGeometries() 内部实现简化版
function mergeGeometries(geometries) {
const mergedGeometry = new BufferGeometry();
// 1. 合并所有顶点的 position 数据
const mergedPositions = [];
const mergedColors = [];
const mergedNormals = [];
let vertexOffset = 0;
geometries.forEach((geometry) => {
const positions = geometry.attributes.position.array;
const colors = geometry.attributes.color?.array || [];
// 将每个几何体的顶点数据追加到合并数组中
for (let i = 0; i < positions.length; i += 3) {
mergedPositions.push(positions[i], positions[i + 1], positions[i + 2]);
if (colors.length > 0) {
mergedColors.push(colors[i], colors[i + 1], colors[i + 2]);
}
}
// 更新索引(如果有索引缓冲区)
if (geometry.index) {
const indices = geometry.index.array;
indices.forEach((index) => {
mergedGeometry.indices.push(index + vertexOffset);
});
vertexOffset += positions.length / 3; // 更新顶点偏移量
}
});
// 2. 创建合并后的 Buffer
mergedGeometry.setAttribute(
"position",
new Float32BufferAttribute(mergedPositions, 3)
);
if (mergedColors.length > 0) {
mergedGeometry.setAttribute(
"color",
new Float32BufferAttribute(mergedColors, 3)
);
}
return mergedGeometry;
}
// 使用示例:
const geo1 = new THREE.BoxGeometry(1, 1, 1);
const geo2 = new THREE.SphereGeometry(0.5, 32, 32);
const geo3 = new THREE.ConeGeometry(0.5, 1, 32);
// 合并三个几何体为一个
const mergedGeo = THREE.BufferGeometry.mergeGeometries([geo1, geo2, geo3]);
// 现在只需要一次 Draw Call 就能绘制所有对象
const mesh = new THREE.Mesh(mergedGeo, material);
2.2 实例化渲染(Instanced Rendering)
问题:渲染大量相同模型(如路锥、树木)时,每个对象都需要单独的 Draw Call。
Three.js 优化:
// 使用 InstancedMesh,一次 Draw Call 渲染成千上万个对象
const geometry = new THREE.BoxGeometry();
const material = new THREE.MeshBasicMaterial();
const instancedMesh = new THREE.InstancedMesh(geometry, material, 10000);
// 设置每个实例的变换矩阵
const matrix = new THREE.Matrix4();
for (let i = 0; i < 10000; i++) {
matrix.setPosition(x, y, z);
instancedMesh.setMatrixAt(i, matrix);
}
Three.js InstancedMesh.setMatrixAt() 的实现原理(简化版):
// Three.js InstancedMesh 内部实现简化版
class InstancedMesh {
constructor(geometry, material, count) {
this.geometry = geometry;
this.material = material;
this.count = count; // 实例数量
// 1. 创建实例矩阵 Buffer(每个矩阵 16 个 float = 4x4 矩阵)
// 存储格式:matrix0[16], matrix1[16], matrix2[16], ...
const matrixArray = new Float32Array(count * 16);
this.instanceMatrix = new InstancedBufferAttribute(matrixArray, 16);
this.instanceMatrix.setUsage(StaticDrawUsage);
// 2. 将实例矩阵作为属性添加到几何体
this.geometry.setAttribute("instanceMatrix", this.instanceMatrix);
}
// 设置第 index 个实例的变换矩阵
setMatrixAt(index, matrix) {
// 矩阵是 4x4,共 16 个元素
const array = this.instanceMatrix.array;
const offset = index * 16; // 每个矩阵占 16 个 float
// 将 Matrix4 的 16 个元素复制到 Buffer 中
// Matrix4 内部存储为列主序(column-major)
array[offset + 0] = matrix.elements[0];
array[offset + 1] = matrix.elements[1];
array[offset + 2] = matrix.elements[2];
array[offset + 3] = matrix.elements[3];
array[offset + 4] = matrix.elements[4];
array[offset + 5] = matrix.elements[5];
array[offset + 6] = matrix.elements[6];
array[offset + 7] = matrix.elements[7];
array[offset + 8] = matrix.elements[8];
array[offset + 9] = matrix.elements[9];
array[offset + 10] = matrix.elements[10];
array[offset + 11] = matrix.elements[11];
array[offset + 12] = matrix.elements[12];
array[offset + 13] = matrix.elements[13];
array[offset + 14] = matrix.elements[14];
array[offset + 15] = matrix.elements[15];
// 标记需要更新
this.instanceMatrix.needsUpdate = true;
}
// 渲染时调用(WebGLRenderer 内部)
render(renderer) {
// 更新 Buffer 数据到 GPU
if (this.instanceMatrix.needsUpdate) {
const gl = renderer.getContext();
const buffer = this.instanceMatrix.buffer;
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.instanceMatrix.array);
this.instanceMatrix.needsUpdate = false;
}
// 使用 WebGL 实例化渲染 API
// gl.drawArraysInstanced(gl.TRIANGLES, 0, vertexCount, this.count);
// 或
// gl.drawElementsInstanced(gl.TRIANGLES, indexCount, gl.UNSIGNED_SHORT, 0, this.count);
}
}
// 在 Shader 中,实例矩阵作为属性传递
// Vertex Shader 示例:
/*
attribute mat4 instanceMatrix; // 4x4 矩阵属性
void main() {
// 将顶点位置乘以实例矩阵(每个实例不同的变换)
vec4 worldPos = instanceMatrix * vec4(position, 1.0);
gl_Position = projectionMatrix * viewMatrix * worldPos;
}
*/
性能提升:
- Draw Call 减少:从 N 次减少到 1 次(N = 对象数量)
- CPU 开销减少:减少 90%+ 的 CPU 时间
- 适用场景:大量重复模型(障碍物、标志牌、粒子系统)
2.3 自动剔除(Culling)
问题:渲染不在视野内的对象浪费 GPU 资源。
Three.js 优化:
- 视锥剔除(Frustum Culling):自动剔除相机视野外的对象
- 背面剔除(Back-face Culling):自动剔除背向相机的面
- 遮挡剔除(Occlusion Culling):高级功能,剔除被遮挡的对象
性能提升:
// 原生 WebGL:需要手动实现
function isInFrustum(object, camera) {
// 手动计算包围盒和视锥的交集
// ... 复杂的数学计算 ...
}
// Three.js:自动处理
// 只需设置 renderer.frustumCulled = true(默认开启)
2.4 几何体合并与 LOD
问题:大量小对象导致 Draw Call 过多。
Three.js 优化:
- 几何体合并:
BufferGeometry.mergeGeometries()将多个几何体合并为一个 - LOD(细节层次):根据距离自动切换不同精度的模型
- Billboard:远距离对象使用 2D 贴图代替 3D 模型
性能提升:
// LOD 系统
const lod = new THREE.LOD();
lod.addLevel(highDetailMesh, 0); // 近距离:高精度
lod.addLevel(mediumDetailMesh, 50); // 中距离:中精度
lod.addLevel(lowDetailMesh, 100); // 远距离:低精度
2.5 内存管理优化
问题:频繁创建和销毁 Buffer 导致内存碎片和性能下降。
Three.js 优化:
- Buffer 复用:自动复用相同大小的 Buffer
- 对象池(Object Pooling):复用几何体、材质等对象
- 自动垃圾回收优化:减少临时对象创建
性能提升:
// Three.js 内部自动处理 Buffer 复用
// 当你更新 BufferGeometry 时,Three.js 会智能地决定:
// 1. 复用现有 Buffer(如果大小合适)
// 2. 创建新 Buffer(如果大小不匹配)
// 3. 自动释放旧 Buffer(当不再使用时)
对象池(Object Pooling)的实现原理(简化版):
// === 问题:频繁创建和销毁对象导致性能问题 ===
// ❌ 不好的做法:每次都创建新对象
function createParticle() {
const geometry = new THREE.BufferGeometry(); // 每次都新建
const material = new THREE.MeshBasicMaterial(); // 每次都新建
return new THREE.Mesh(geometry, material);
}
// 创建 1000 个粒子 = 1000 次 new 操作 + 1000 次 GC 压力
for (let i = 0; i < 1000; i++) {
const particle = createParticle();
// 使用完后被垃圾回收
}
// === 解决方案:对象池(开发者自己实现)===
// 注意:Three.js 内部并没有提供通用的 ObjectPool 类
// 如果需要对象池优化,开发者需要自己实现
class ObjectPool {
constructor(createFn, resetFn, initialSize = 10) {
this.createFn = createFn; // 创建对象的函数
this.resetFn = resetFn; // 重置对象的函数
this.pool = []; // 对象池
this.active = []; // 正在使用的对象
// 预创建一些对象
for (let i = 0; i < initialSize; i++) {
this.pool.push(this.createFn());
}
}
// 从池中获取对象
acquire() {
let obj;
if (this.pool.length > 0) {
obj = this.pool.pop(); // 复用池中的对象
} else {
obj = this.createFn(); // 池空了,创建新对象
}
this.active.push(obj);
return obj;
}
// 归还对象到池中
release(obj) {
const index = this.active.indexOf(obj);
if (index > -1) {
this.active.splice(index, 1);
this.resetFn(obj); // 重置对象状态
this.pool.push(obj); // 放回池中
}
}
}
// === 开发者使用对象池的示例 ===
// 1. 几何体对象池(开发者自己实现)
const geometryPool = new ObjectPool(
() => new THREE.BufferGeometry(), // 创建
(geo) => {
// 重置:清空所有属性
geo.dispose();
geo.attributes = {};
geo.index = null;
}
);
// 2. 材质对象池(开发者自己实现)
const materialPool = new ObjectPool(
() => new THREE.MeshBasicMaterial(),
(mat) => {
mat.color.set(0xffffff);
mat.opacity = 1.0;
}
);
// 使用对象池
function createParticleOptimized() {
const geometry = geometryPool.acquire(); // 从池中获取
const material = materialPool.acquire();
return new THREE.Mesh(geometry, material);
}
function destroyParticle(mesh) {
geometryPool.release(mesh.geometry); // 归还到池中
materialPool.release(mesh.material);
// mesh 本身可以被 GC,但 geometry 和 material 被复用了
}
// === Three.js 内部的实际优化(Buffer 复用)===
// Three.js 内部的对象复用主要体现在 Buffer 复用上
// 当你更新 BufferGeometry 时,Three.js 会智能地复用 Buffer:
class BufferGeometry {
setAttribute(name, attribute) {
const currentAttribute = this.attributes[name];
// 如果 Buffer 大小匹配,复用现有 Buffer
if (
currentAttribute &&
currentAttribute.buffer &&
currentAttribute.array.length === attribute.array.length
) {
// 复用 Buffer,只更新数据
gl.bindBuffer(gl.ARRAY_BUFFER, currentAttribute.buffer);
gl.bufferSubData(gl.ARRAY_BUFFER, 0, attribute.array);
} else {
// 大小不匹配,创建新 Buffer
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, attribute.array, gl.STATIC_DRAW);
attribute.buffer = buffer;
}
this.attributes[name] = attribute;
}
}
// === 自动垃圾回收优化 ===
// ❌ 不好的做法:每帧创建临时对象
function animate() {
// 每帧都创建新的 Vector3,增加 GC 压力
const position = new THREE.Vector3(
Math.random(),
Math.random(),
Math.random()
);
mesh.position.copy(position);
// position 在函数结束后被 GC
}
// ✅ 好的做法:复用对象
const tempPosition = new THREE.Vector3(); // 在外部创建,复用
function animateOptimized() {
// 复用同一个对象,减少 GC 压力
tempPosition.set(Math.random(), Math.random(), Math.random());
mesh.position.copy(tempPosition);
// tempPosition 不会被 GC,下次继续使用
}
// === Three.js 内部的真实优化(临时对象预创建)===
// Three.js 内部确实有临时对象预创建的优化
// 在 WebGLRenderer、Object3D 等类中都有预创建的临时对象
class WebGLRenderer {
constructor() {
// Three.js 内部确实预创建了这些临时对象
this._vector3 = new THREE.Vector3();
this._matrix4 = new THREE.Matrix4();
this._quaternion = new THREE.Quaternion();
this._color = new THREE.Color();
// ... 还有其他临时对象
}
render(scene, camera) {
// 使用预创建的对象,而不是每帧 new Vector3()
this._vector3.set(0, 0, 0);
// ... 使用 _vector3 进行计算
// 函数结束后,_vector3 不会被 GC,下次继续使用
}
}
// 同样,Object3D 类也有临时对象
class Object3D {
constructor() {
// 内部预创建的临时对象(简化版)
this._tempVector = new THREE.Vector3();
this._tempMatrix = new THREE.Matrix4();
}
updateMatrix() {
// 使用预创建的临时对象进行计算
this._tempMatrix.compose(this.position, this.quaternion, this.scale);
// ...
}
}
性能对比:
// ❌ 无对象池(开发者自己实现):1000 个粒子
// 创建时间:1000 × 0.1ms = 100ms
// GC 压力:高(频繁创建/销毁)
// 内存碎片:严重
// ✅ 使用对象池(开发者自己实现):1000 个粒子
// 创建时间:10 × 0.1ms = 1ms(预创建 10 个,其余复用)
// GC 压力:低(对象被复用)
// 内存碎片:少
// 性能提升:100 倍!
// === Three.js 内部的优化总结 ===
// 1. Buffer 复用:自动复用相同大小的 Buffer(内部实现)
// 2. 临时对象预创建:_vector3、_matrix4 等(内部实现)
// 3. 对象池:需要开发者自己实现(Three.js 不提供通用 ObjectPool)
2.6 矩阵计算优化
问题:每帧都需要重新计算大量矩阵(模型矩阵、视图矩阵、投影矩阵)。
Three.js 优化:
- 惰性计算(Lazy Evaluation):只在需要时才计算矩阵
- 矩阵缓存:缓存计算结果,避免重复计算
- 预计算 modelViewMatrix:在 CPU 端预计算
viewMatrix * modelMatrix,减少 GPU 计算量
性能提升:
// 原生 WebGL:每个顶点都要计算 viewMatrix * modelMatrix
gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0);
// GPU 需要执行:1000 个顶点 × 2 次矩阵乘法 = 2000 次矩阵乘法
// Three.js:预计算 modelViewMatrix
const modelViewMatrix = viewMatrix * modelMatrix; // CPU 端计算一次
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
// GPU 只需执行:1000 个顶点 × 1 次矩阵乘法 = 1000 次矩阵乘法
// 性能提升:减少 50% 的 GPU 矩阵乘法计算量
2.7 渲染器配置优化
Three.js 提供了大量渲染器配置选项,可以针对不同场景优化:
const renderer = new THREE.WebGLRenderer({
// 性能相关配置
powerPreference: "high-performance", // 优先使用独立显卡
precision: "mediump", // 移动端使用中等精度
logarithmicDepthBuffer: true, // 解决大场景深度冲突
antialias: false, // 关闭抗锯齿提升性能
});
// 限制像素比,避免过高开销
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
// 自动清理,避免内存泄漏
renderer.dispose();
3. 对比总结
| 方面 | 原生 WebGL | Three.js | 优势 |
|---|---|---|---|
| 代码量 | ~100 行(简单三角形) | ~10 行 | 减少 90% |
| Buffer 管理 | 手动创建、绑定、上传 | 自动管理 | 减少错误,提升可维护性 |
| Shader 编译 | 手动编译、链接、错误处理 | 自动处理 | 统一错误处理 |
| 状态管理 | 手动切换所有状态 | 自动优化 | 减少状态切换开销 |
| 矩阵计算 | 手动计算 MVP | 自动计算 | 减少计算错误 |
| 批处理 | 需要手动实现 | 自动批处理 | 减少 Draw Call |
| 实例化渲染 | 需要手动实现 | InstancedMesh | 简化大量对象渲染 |
| 剔除 | 需要手动实现 | 自动剔除 | 减少无效渲染 |
| 内存管理 | 手动管理 | 自动优化 | 减少内存泄漏 |
4. 何时使用原生 WebGL?
虽然 Three.js 提供了强大的封装和优化,但在以下场景下,原生 WebGL 仍然有其价值:
学习目的:深入理解底层原理
极致性能:需要完全控制渲染流程的特定场景
特殊需求:Three.js 不支持的特殊 WebGL 特性
WebGL 2.0 高级特性:
Transform Feedback:GPU 端数据流处理
// 用途:粒子系统、物理模拟(在 GPU 上更新粒子位置)
// Three.js 不支持,需要原生 WebGL 2.0
const transformFeedback = gl.createTransformFeedback();
gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, transformFeedback);
gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, outputBuffer);
gl.beginTransformFeedback(gl.POINTS);
gl.drawArrays(gl.POINTS, 0, particleCount);
gl.endTransformFeedback();Uniform Buffer Objects (UBO):批量 uniform 传递
// 用途:一次传递大量 uniform,减少 API 调用
// Three.js 不支持,需要原生 WebGL 2.0
const ubo = gl.createBuffer();
gl.bindBuffer(gl.UNIFORM_BUFFER, ubo);
gl.bufferData(gl.UNIFORM_BUFFER, uniformData, gl.STATIC_DRAW);
gl.bindBufferBase(gl.UNIFORM_BUFFER, 0, ubo);Multiple Render Targets (MRT):一次渲染输出到多个纹理
// 用途:延迟渲染(G-Buffer)、后处理效果
// Three.js 的 RenderTarget 只支持单个输出
const framebuffer = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
gl.framebufferTexture2D(
gl.FRAMEBUFFER,
gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D,
texture0,
0
);
gl.framebufferTexture2D(
gl.FRAMEBUFFER,
gl.COLOR_ATTACHMENT1,
gl.TEXTURE_2D,
texture1,
0
);
gl.drawBuffers([gl.COLOR_ATTACHMENT0, gl.COLOR_ATTACHMENT1]);Texture Arrays:纹理数组
// 用途:地形渲染、动画序列、体积渲染
// Three.js 不支持,需要原生 WebGL 2.0
const textureArray = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D_ARRAY, textureArray);
gl.texImage3D(
gl.TEXTURE_2D_ARRAY,
0,
gl.RGBA,
width,
height,
depth,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
data
);
底层渲染控制:
Occlusion Query:遮挡查询
// 用途:精确的可见性检测,优化渲染
// Three.js 有部分支持,但功能有限
const query = gl.createQuery();
gl.beginQuery(gl.ANY_SAMPLES_PASSED, query);
gl.drawArrays(gl.TRIANGLES, 0, vertexCount);
gl.endQuery(gl.ANY_SAMPLES_PASSED);
const visible = gl.getQueryParameter(query, gl.QUERY_RESULT);Conditional Rendering:条件渲染
// 用途:根据查询结果决定是否渲染(减少无效渲染)
// Three.js 不支持
gl.beginConditionalRender(query, gl.QUERY_WAIT);
gl.drawArrays(gl.TRIANGLES, 0, vertexCount);
gl.endConditionalRender();Indirect Drawing:间接绘制
// 用途:GPU 驱动的渲染,减少 CPU-GPU 通信
// Three.js 不支持,需要 WebGL 2.0 + 扩展
gl.drawArraysIndirect(gl.TRIANGLES, indirectBuffer);Compute Shaders:计算着色器
// 用途:通用 GPU 计算(非图形渲染)
// WebGL 2.0 不支持,需要 WebGPU 或 WebGL 2.0 Compute 扩展
// Three.js 不支持
特殊渲染技术:
Geometry Shaders:几何着色器
- WebGL 2.0 不支持(OpenGL ES 3.2+ 才支持)
- 用途:动态生成几何体、粒子系统
Tessellation Shaders:细分着色器
- WebGL 2.0 不支持(OpenGL ES 3.2+ 才支持)
- 用途:动态 LOD、曲面细分
自定义渲染管线:完全自定义的渲染流程
- Three.js 的渲染流程是固定的,无法完全自定义
- 原生 WebGL 可以完全控制渲染管线
包体积限制:Three.js 体积较大(~600KB),对包体积敏感的应用
5. 总结
Three.js 的伟大之处不仅在于简化了开发,更在于在性能上做了大量优化:
- 封装层面:将 100 行代码简化为 10 行,自动处理 Buffer、Shader、状态等
- 性能层面:批处理、实例化渲染、自动剔除、矩阵优化等,大幅提升渲染性能
- 工程化层面:模块化 Shader、统一错误处理、自动内存管理,提升代码质量
理解原生 WebGL 让我们知道 Three.js 在做什么,而使用 Three.js 让我们能够专注于业务逻辑,而不是底层细节。这就是为什么 Three.js 成为 Web 3D 开发的首选框架。
📝 编程作业
难度:⭐⭐⭐ 困难 | 预计时间:90-120 分钟
任务要求
不使用 Three.js,直接使用原生 WebGL API 完成以下任务。
具体要求
基础任务(必做)
绘制彩色三角形:
- 手动创建 WebGL 上下文
- 手动编写和编译顶点着色器、片段着色器
- 手动创建和绑定 Buffer
- 手动设置
vertexAttribPointer配置属性 - 手动调用
drawArrays绘制三角形 - 实现三个顶点不同颜色的渐变效果
绘制矩形:
- 使用索引缓冲区(
ELEMENT_ARRAY_BUFFER)绘制矩形 - 理解
drawElements和drawArrays的区别 - 实现矩形的颜色渐变
- 使用索引缓冲区(
进阶任务(选做)
实现动画效果:
- 添加
requestAnimationFrame渲染循环 - 使用
uniform传递时间变量uTime - 在 Shader 中实现颜色或位置的动画效果
- 添加
实现矩阵变换:
- 手动计算模型矩阵(平移、旋转、缩放)
- 手动计算投影矩阵(透视投影或正交投影)
- 在顶点着色器中使用矩阵变换顶点位置
代码框架
<!DOCTYPE html>
<html>
<head>
<title>原生 WebGL 作业</title>
<style>
body {
margin: 0;
overflow: hidden;
}
canvas {
width: 100vw;
height: 100vh;
display: block;
}
</style>
</head>
<body>
<canvas id="glcanvas"></canvas>
<script>
// TODO: 实现完整的 WebGL 渲染流程
// 1. 获取 WebGL 上下文
// 2. 编写 Shader 源码
// 3. 编译和链接 Shader
// 4. 创建 Buffer 并上传数据
// 5. 配置 vertexAttribPointer
// 6. 绘制图形
</script>
</body>
</html>
实现提示
任务 1:彩色三角形
// 顶点数据:位置 (x, y) + 颜色 (r, g, b)
const vertices = new Float32Array([
0.0,
0.5,
1.0,
0.0,
0.0, // 顶点1: 上方, 红色
-0.5,
-0.5,
0.0,
1.0,
0.0, // 顶点2: 左下, 绿色
0.5,
-0.5,
0.0,
0.0,
1.0, // 顶点3: 右下, 蓝色
]);
// 关键步骤:
// 1. gl.createBuffer()
// 2. gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
// 3. gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW)
// 4. gl.getAttribLocation(program, "aVertexPosition")
// 5. gl.enableVertexAttribArray(location)
// 6. gl.vertexAttribPointer(location, size, type, normalized, stride, offset)
// 7. gl.drawArrays(gl.TRIANGLES, 0, 3)
任务 2:使用索引绘制矩形
// 顶点数据(4个顶点)
const vertices = new Float32Array([
-0.5,
0.5,
1.0,
0.0,
0.0, // 左上
0.5,
0.5,
0.0,
1.0,
0.0, // 右上
0.5,
-0.5,
0.0,
0.0,
1.0, // 右下
-0.5,
-0.5,
1.0,
1.0,
0.0, // 左下
]);
// 索引数据(两个三角形组成矩形)
const indices = new Uint16Array([
0,
1,
2, // 第一个三角形
0,
2,
3, // 第二个三角形
]);
// 关键步骤:
// 1. 创建索引 Buffer:gl.ELEMENT_ARRAY_BUFFER
// 2. 使用 gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0)
任务 3:动画效果
// 在 Shader 中添加 uniform
uniform float uTime;
// 在渲染循环中更新
function animate() {
const time = performance.now() * 0.001; // 转换为秒
// 获取 uniform 位置
const timeLoc = gl.getUniformLocation(shaderProgram, "uTime");
gl.uniform1f(timeLoc, time);
// 绘制
gl.drawArrays(gl.TRIANGLES, 0, 3);
requestAnimationFrame(animate);
}
检查清单
- 不使用任何 Three.js 代码
- 手动完成所有 WebGL API 调用
- 正确配置
vertexAttribPointer(理解 stride 和 offset) - 实现颜色插值效果
- 代码有清晰的注释,说明每个步骤的作用
- 理解 Buffer、Shader、Program 的关系
扩展挑战
- 🌟 实现多个图形(三角形、矩形、圆形)
- 🌟 实现鼠标交互(点击改变颜色或位置)
- 🌟 实现纹理映射(手动加载图片并绑定到纹理)
- 🌟 实现深度测试(Z-buffer)和 3D 场景
参考资源
- 完整作业清单:编程作业清单.md
- WebGL 官方教程:WebGL Fundamentals
- MDN WebGL 文档:WebGL API