Skip to main content

09. 进阶实战:纯原生 WebGL (No Three.js)

虽然 Three.js 极大地简化了开发,但为了彻底掌握底层原理,没有任何方法比亲手写一遍纯原生 WebGL API 更好的了。

这就像是学会了开车(Three.js),现在我们要打开引擎盖看看发动机(WebGL)。

目标

脱离 Three.js,直接调用 WebGL API 绘制一个彩色的三角形。

核心流程

原生 WebGL 的步骤非常繁琐,但逻辑很清晰:

  1. 准备画布:获取 gl 上下文。
  2. 创建着色器:编译 GLSL 代码。
  3. 创建程序:链接顶点和片段着色器。
  4. 准备数据:把顶点坐标塞进缓冲区 (Buffer)。
  5. 配置属性:告诉 GPU 怎么读取缓冲区的数据。
  6. 绘制:调用 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>

关键点解析

  1. Context (上下文)canvas.getContext("webgl") 是所有操作的起点。
  2. Shader 编译:在 Three.js 中这是自动的,但在原生 WebGL 中,你需要手动调用 compileShaderlinkProgram
  3. Buffer (缓冲区):GPU 不能直接读取 JS 数组。必须先创建 Buffer,绑定它,然后用 gl.bufferData 把数据“推”过去。
  4. VertexAttribPointer:这是最难理解也是最重要的一步。因为我们把位置和颜色混在一个数组里了(交错数组),所以必须告诉 GPU:
    • “每隔 20 个字节读一次。” (Stride)
    • “位置从第 0 个字节开始读 2 个数。” (Offset 0)
    • “颜色从第 8 个字节开始读 3 个数。” (Offset 8)

为什么 Three.js 伟大?

看完这个例子,你应该能体会到。仅仅是为了画一个不动的三角形,我们就写了近 100 行代码,涉及了极其繁琐的 Buffer 管理和内存偏移量计算。

Three.js 的 BufferGeometryMaterial 实际上就是帮你把这 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(如 projectionMatrixviewMatrixmodelMatrix
  • 支持 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. 对比总结

方面原生 WebGLThree.js优势
代码量~100 行(简单三角形)~10 行减少 90%
Buffer 管理手动创建、绑定、上传自动管理减少错误,提升可维护性
Shader 编译手动编译、链接、错误处理自动处理统一错误处理
状态管理手动切换所有状态自动优化减少状态切换开销
矩阵计算手动计算 MVP自动计算减少计算错误
批处理需要手动实现自动批处理减少 Draw Call
实例化渲染需要手动实现InstancedMesh简化大量对象渲染
剔除需要手动实现自动剔除减少无效渲染
内存管理手动管理自动优化减少内存泄漏

4. 何时使用原生 WebGL?

虽然 Three.js 提供了强大的封装和优化,但在以下场景下,原生 WebGL 仍然有其价值:

  1. 学习目的:深入理解底层原理

  2. 极致性能:需要完全控制渲染流程的特定场景

  3. 特殊需求: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 可以完全控制渲染管线
  4. 包体积限制: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 完成以下任务。

具体要求

基础任务(必做)

  1. 绘制彩色三角形

    • 手动创建 WebGL 上下文
    • 手动编写和编译顶点着色器、片段着色器
    • 手动创建和绑定 Buffer
    • 手动设置 vertexAttribPointer 配置属性
    • 手动调用 drawArrays 绘制三角形
    • 实现三个顶点不同颜色的渐变效果
  2. 绘制矩形

    • 使用索引缓冲区(ELEMENT_ARRAY_BUFFER)绘制矩形
    • 理解 drawElementsdrawArrays 的区别
    • 实现矩形的颜色渐变

进阶任务(选做)

  1. 实现动画效果

    • 添加 requestAnimationFrame 渲染循环
    • 使用 uniform 传递时间变量 uTime
    • 在 Shader 中实现颜色或位置的动画效果
  2. 实现矩阵变换

    • 手动计算模型矩阵(平移、旋转、缩放)
    • 手动计算投影矩阵(透视投影或正交投影)
    • 在顶点着色器中使用矩阵变换顶点位置

代码框架

<!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 场景

参考资源