Skip to content

浅识WebGL

· 38 min

前言#

说起WebGL,我最开始听到这个词的时候,脑子里第一反应是”又是一个听起来很高大上的技术”。后来真正接触了才发现,这玩意儿其实就像是给浏览器装了个小型的3D引擎,让网页也能跑出游戏级别的效果。不过话说回来,直接上手WebGL就像是拿着汇编语言去写网站——理论上可行,实际上会让人怀疑人生。

所以今天咱们聊聊Three.js,这个被誉为”WebGL救星”的JavaScript库。但在深入Three.js之前,我觉得有必要先聊聊WebGL本身,毕竟知其然还要知其所以然嘛。

WebGL到底是个什么东西?#

从历史说起#

要理解WebGL,得先从它的”祖宗”OpenGL说起。OpenGL(Open Graphics Library)是一个跨平台的图形API,诞生于1992年,主要用于渲染2D和3D图形。你玩的游戏、看的3D电影,很多都是基于OpenGL或者它的兄弟DirectX渲染出来的。

WebGL说白了就是OpenGL ES(OpenGL的精简版)在浏览器里的实现。它让JavaScript能够直接调用显卡的GPU来进行图形渲染,而不用依赖任何插件。这就像是给网页开发者发了一张”直通显卡”的VIP卡。

WebGL的工作原理#

WebGL的核心思想是利用GPU的并行计算能力。CPU虽然强大,但它更像是一个聪明的管理者,一次只能专心做一件事。而GPU就像是一个拥有成千上万个工人的工厂,虽然每个工人都不太聪明,但胜在人多力量大,特别适合做那些重复性的计算工作。

渲染3D图形恰好就是这样的工作:需要对成千上万个像素进行相似的计算。所以GPU天生就是为图形渲染而生的。

渲染管线:从3D到2D的魔法#

WebGL的渲染过程可以简化为这样几个步骤:

  1. 顶点处理:定义3D物体的形状(顶点坐标)
  2. 图元装配:把顶点连接成三角形
  3. 光栅化:把三角形转换成像素
  4. 片元处理:给每个像素上色
  5. 输出:显示到屏幕上

这个过程就像是把一个3D雕塑拍成2D照片:先确定雕塑的形状,然后从某个角度拍照,最后冲洗出来。

为什么要用Three.js?#

原生WebGL的”酸爽”体验#

让我们先看看用原生WebGL画一个三角形需要多少代码:

// 获取WebGL上下文
const canvas = document.getElementById('canvas');
const gl = canvas.getContext('webgl');
// 顶点着色器源码
const vertexShaderSource = `
attribute vec4 a_position;
void main() {
gl_Position = a_position;
}
`;
// 片元着色器源码
const fragmentShaderSource = `
precision mediump float;
void main() {
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}
`;
// 创建着色器
function createShader(gl, type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
return shader;
}
// 创建程序
function createProgram(gl, vertexShader, fragmentShader) {
const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
return program;
}
// 编译着色器
const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexShaderSource);
const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource);
// 创建程序
const program = createProgram(gl, vertexShader, fragmentShader);
// 定义三角形顶点
const positions = [
0.0, 0.5,
-0.5, -0.5,
0.5, -0.5,
];
// 创建缓冲区
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
// 获取属性位置
const positionAttributeLocation = gl.getAttribLocation(program, "a_position");
// 设置视口
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
// 清空画布
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
// 使用程序
gl.useProgram(program);
// 启用属性
gl.enableVertexAttribArray(positionAttributeLocation);
// 绑定缓冲区
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
// 告诉属性如何从缓冲区读取数据
gl.vertexAttribPointer(positionAttributeLocation, 2, gl.FLOAT, false, 0, 0);
// 绘制
gl.drawArrays(gl.TRIANGLES, 0, 3);

看到这里,你是不是已经开始头疼了?这还只是画一个红色三角形!如果要画一个旋转的立方体,代码量至少要翻十倍。

Three.js的”人话”版本#

同样的效果,用Three.js只需要:

// 创建场景
const scene = new THREE.Scene();
// 创建相机
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
// 创建渲染器
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// 创建几何体
const geometry = new THREE.BufferGeometry();
const vertices = new Float32Array([
0.0, 0.5, 0.0,
-0.5, -0.5, 0.0,
0.5, -0.5, 0.0
]);
geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
// 创建材质
const material = new THREE.MeshBasicMaterial({ color: 0xff0000 });
// 创建网格
const triangle = new THREE.Mesh(geometry, material);
scene.add(triangle);
// 设置相机位置
camera.position.z = 5;
// 渲染
renderer.render(scene, camera);

这就是Three.js的魅力所在——它把复杂的WebGL操作封装成了简单易懂的API,让我们能专注于创意而不是底层实现。

深入理解Three.js的核心概念#

场景(Scene):3D世界的舞台#

Scene就像是一个3D世界的容器,所有的物体、光源、相机都要放在这个容器里。你可以把它想象成一个电影摄影棚,里面可以摆放各种道具、布置灯光、安排摄像机位置。

const scene = new THREE.Scene();
// 设置背景色
scene.background = new THREE.Color(0x87CEEB); // 天蓝色
// 设置雾效
scene.fog = new THREE.Fog(0xcccccc, 10, 15);
// 添加物体到场景
scene.add(mesh);
scene.add(light);

相机(Camera):观察世界的眼睛#

Three.js提供了几种不同类型的相机,最常用的是透视相机(PerspectiveCamera)和正交相机(OrthographicCamera)。

透视相机(PerspectiveCamera)#

透视相机模拟人眼的视觉效果,近大远小,有透视感:

const camera = new THREE.PerspectiveCamera(
75, // 视野角度(FOV)
window.innerWidth / window.innerHeight, // 宽高比
0.1, // 近裁剪面
1000 // 远裁剪面
);
// 设置相机位置
camera.position.set(0, 0, 5);
// 让相机看向某个点
camera.lookAt(0, 0, 0);

视野角度(FOV)就像是镜头的广角程度,角度越大,看到的范围越广,但物体会显得更小。就像用广角镜头拍照一样。

正交相机(OrthographicCamera)#

正交相机没有透视效果,远近物体大小相同,常用于建筑图纸、工程图等:

const camera = new THREE.OrthographicCamera(
-window.innerWidth / 2, // 左边界
window.innerWidth / 2, // 右边界
window.innerHeight / 2, // 上边界
-window.innerHeight / 2, // 下边界
0.1, // 近裁剪面
1000 // 远裁剪面
);

渲染器(Renderer):把3D世界画到屏幕上#

渲染器负责把3D场景转换成2D图像显示在屏幕上。WebGLRenderer是最常用的渲染器:

const renderer = new THREE.WebGLRenderer({
antialias: true, // 开启抗锯齿
alpha: true, // 支持透明背景
preserveDrawingBuffer: true // 保留绘图缓冲区,用于截图
});
// 设置渲染尺寸
renderer.setSize(window.innerWidth, window.innerHeight);
// 设置像素比,解决高分辨率屏幕模糊问题
renderer.setPixelRatio(window.devicePixelRatio);
// 开启阴影
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
// 设置色调映射
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;

几何体(Geometry):构建3D形状的积木#

内置几何体#

Three.js提供了丰富的内置几何体,就像乐高积木一样,可以直接使用:

// 立方体
const boxGeometry = new THREE.BoxGeometry(1, 1, 1);
// 球体
const sphereGeometry = new THREE.SphereGeometry(
1, // 半径
32, // 水平分段数
16 // 垂直分段数
);
// 圆柱体
const cylinderGeometry = new THREE.CylinderGeometry(
1, // 顶部半径
1, // 底部半径
2, // 高度
32 // 径向分段数
);
// 圆锥体
const coneGeometry = new THREE.ConeGeometry(1, 2, 32);
// 环形
const torusGeometry = new THREE.TorusGeometry(
1, // 环形半径
0.3, // 管道半径
16, // 径向分段数
100 // 管道分段数
);
// 平面
const planeGeometry = new THREE.PlaneGeometry(2, 2);

自定义几何体#

有时候内置几何体满足不了需求,就需要自定义几何体:

const geometry = new THREE.BufferGeometry();
// 定义顶点坐标
const vertices = new Float32Array([
// 第一个三角形
-1.0, -1.0, 1.0,
1.0, -1.0, 1.0,
1.0, 1.0, 1.0,
// 第二个三角形
1.0, 1.0, 1.0,
-1.0, 1.0, 1.0,
-1.0, -1.0, 1.0
]);
// 定义法向量(用于光照计算)
const normals = new Float32Array([
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1
]);
// 定义UV坐标(用于纹理映射)
const uvs = new Float32Array([
0, 0,
1, 0,
1, 1,
1, 1,
0, 1,
0, 0
]);
// 设置属性
geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
geometry.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
geometry.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));

材质(Material):给物体穿上”衣服”#

材质决定了物体的外观,就像给演员化妆一样重要。

基础材质(MeshBasicMaterial)#

最简单的材质,不受光照影响,就像自带光环:

const material = new THREE.MeshBasicMaterial({
color: 0xff0000, // 颜色
transparent: true, // 支持透明
opacity: 0.8, // 透明度
side: THREE.DoubleSide, // 双面渲染
wireframe: true // 线框模式
});

兰伯特材质(MeshLambertMaterial)#

漫反射材质,表面粗糙,没有高光:

const material = new THREE.MeshLambertMaterial({
color: 0x00ff00,
emissive: 0x004400, // 自发光颜色
emissiveIntensity: 0.1 // 自发光强度
});

冯氏材质(MeshPhongMaterial)#

有高光效果的材质,看起来更有质感:

const material = new THREE.MeshPhongMaterial({
color: 0x0000ff,
specular: 0x111111, // 高光颜色
shininess: 100 // 高光强度
});

标准材质(MeshStandardMaterial)#

基于物理的渲染材质,效果最真实:

const material = new THREE.MeshStandardMaterial({
color: 0x888888,
metalness: 0.5, // 金属度
roughness: 0.1, // 粗糙度
envMapIntensity: 1.0 // 环境贴图强度
});

光照系统:让世界亮起来#

没有光的3D世界就像是黑灯瞎火的房间,啥也看不清。Three.js提供了多种光源类型。

环境光(AmbientLight)#

环境光就像是房间里的基础照明,均匀地照亮所有物体:

const ambientLight = new THREE.AmbientLight(
0x404040, // 颜色
0.4 // 强度
);
scene.add(ambientLight);

方向光(DirectionalLight)#

方向光像太阳光一样,从无限远处平行照射:

const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
directionalLight.position.set(5, 5, 5);
directionalLight.target.position.set(0, 0, 0);
// 开启阴影
directionalLight.castShadow = true;
directionalLight.shadow.mapSize.width = 2048;
directionalLight.shadow.mapSize.height = 2048;
scene.add(directionalLight);

点光源(PointLight)#

点光源像灯泡一样,从一个点向四周发光:

const pointLight = new THREE.PointLight(
0xffffff, // 颜色
1, // 强度
100 // 距离(光照范围)
);
pointLight.position.set(10, 10, 10);
pointLight.castShadow = true;
scene.add(pointLight);

聚光灯(SpotLight)#

聚光灯像手电筒一样,有方向性和角度:

const spotLight = new THREE.SpotLight(
0xffffff, // 颜色
1, // 强度
100, // 距离
Math.PI / 4, // 角度
0.1 // 边缘衰减
);
spotLight.position.set(0, 10, 0);
spotLight.target.position.set(0, 0, 0);
spotLight.castShadow = true;
scene.add(spotLight);

纹理(Texture):给物体贴上”皮肤”#

纹理就像是给3D物体贴壁纸,让它们看起来更真实。

基础纹理加载#

const textureLoader = new THREE.TextureLoader();
// 加载单个纹理
const texture = textureLoader.load('path/to/texture.jpg');
// 加载完成后的回调
const texture = textureLoader.load(
'path/to/texture.jpg',
function(texture) {
console.log('纹理加载完成');
},
function(progress) {
console.log('加载进度:', progress);
},
function(error) {
console.log('加载失败:', error);
}
);
// 应用到材质
const material = new THREE.MeshBasicMaterial({ map: texture });

纹理属性设置#

// 纹理重复
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
texture.repeat.set(2, 2);
// 纹理偏移
texture.offset.set(0.5, 0.5);
// 纹理旋转
texture.rotation = Math.PI / 4;
texture.center.set(0.5, 0.5);
// 纹理过滤
texture.magFilter = THREE.LinearFilter;
texture.minFilter = THREE.LinearMipmapLinearFilter;

多种纹理类型#

const textureLoader = new THREE.TextureLoader();
// 漫反射贴图
const diffuseMap = textureLoader.load('diffuse.jpg');
// 法线贴图(用于模拟表面细节)
const normalMap = textureLoader.load('normal.jpg');
// 粗糙度贴图
const roughnessMap = textureLoader.load('roughness.jpg');
// 金属度贴图
const metalnessMap = textureLoader.load('metalness.jpg');
// 环境遮蔽贴图
const aoMap = textureLoader.load('ao.jpg');
const material = new THREE.MeshStandardMaterial({
map: diffuseMap,
normalMap: normalMap,
roughnessMap: roughnessMap,
metalnessMap: metalnessMap,
aoMap: aoMap
});

立方体纹理(天空盒)#

const cubeTextureLoader = new THREE.CubeTextureLoader();
const cubeTexture = cubeTextureLoader.load([
'px.jpg', 'nx.jpg', // 正X, 负X
'py.jpg', 'ny.jpg', // 正Y, 负Y
'pz.jpg', 'nz.jpg' // 正Z, 负Z
]);
// 设置为场景背景
scene.background = cubeTexture;
// 或者用作环境贴图
material.envMap = cubeTexture;

动画系统:让静态世界动起来#

基础动画循环#

function animate() {
requestAnimationFrame(animate);
// 旋转动画
cube.rotation.x += 0.01;
cube.rotation.y += 0.01;
// 位置动画
cube.position.x = Math.sin(Date.now() * 0.001) * 3;
// 缩放动画
const scale = 1 + Math.sin(Date.now() * 0.002) * 0.3;
cube.scale.set(scale, scale, scale);
renderer.render(scene, camera);
}
animate();

使用Tween.js做缓动动画#

// 安装:npm install @tweenjs/tween.js
import * as TWEEN from '@tweenjs/tween.js';
// 创建缓动动画
const tween = new TWEEN.Tween(cube.position)
.to({ x: 5, y: 2, z: 0 }, 2000) // 2秒内移动到目标位置
.easing(TWEEN.Easing.Quadratic.Out)
.onComplete(() => {
console.log('动画完成');
})
.start();
// 在动画循环中更新
function animate() {
requestAnimationFrame(animate);
TWEEN.update();
renderer.render(scene, camera);
}

Three.js内置动画系统#

// 创建动画混合器
const mixer = new THREE.AnimationMixer(model);
// 播放动画
const action = mixer.clipAction(animationClip);
action.play();
// 在动画循环中更新
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const deltaTime = clock.getDelta();
mixer.update(deltaTime);
renderer.render(scene, camera);
}

交互控制:让用户参与进来#

轨道控制器(OrbitControls)#

最常用的相机控制器,让用户可以用鼠标控制视角:

import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
const controls = new OrbitControls(camera, renderer.domElement);
// 配置控制器
controls.enableDamping = true; // 开启阻尼
controls.dampingFactor = 0.05; // 阻尼系数
controls.enableZoom = true; // 允许缩放
controls.enableRotate = true; // 允许旋转
controls.enablePan = true; // 允许平移
// 限制旋转角度
controls.maxPolarAngle = Math.PI / 2; // 最大俯仰角
controls.minPolarAngle = 0; // 最小俯仰角
// 限制缩放距离
controls.minDistance = 1;
controls.maxDistance = 100;
// 在动画循环中更新
function animate() {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}

鼠标拾取(Raycasting)#

检测鼠标点击了哪个物体:

const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
function onMouseClick(event) {
// 将鼠标坐标转换为标准化设备坐标
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
// 从相机发射射线
raycaster.setFromCamera(mouse, camera);
// 检测相交的物体
const intersects = raycaster.intersectObjects(scene.children);
if (intersects.length > 0) {
const clickedObject = intersects[0].object;
console.log('点击了:', clickedObject);
// 改变物体颜色
clickedObject.material.color.setHex(Math.random() * 0xffffff);
}
}
window.addEventListener('click', onMouseClick);

键盘控制#

const keys = {};
// 监听键盘事件
window.addEventListener('keydown', (event) => {
keys[event.code] = true;
});
window.addEventListener('keyup', (event) => {
keys[event.code] = false;
});
// 在动画循环中处理键盘输入
function animate() {
requestAnimationFrame(animate);
// 移动控制
if (keys['KeyW']) camera.position.z -= 0.1;
if (keys['KeyS']) camera.position.z += 0.1;
if (keys['KeyA']) camera.position.x -= 0.1;
if (keys['KeyD']) camera.position.x += 0.1;
renderer.render(scene, camera);
}

我的Three.js学习之路(详细版)#

第一步:搭建基础场景(demo1-basic)#

刚开始学Three.js的时候,我就像个刚进城的乡下人,看什么都新鲜。第一个demo就是创建一个最基础的3D场景,说起来简单,实际上涉及到Three.js的”三大金刚”:

// 创建场景 - 就像搭建一个3D世界的舞台
const scene = new THREE.Scene();
// 创建相机 - 你的眼睛,决定从哪个角度看世界
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
// 创建渲染器 - 把3D世界画到2D屏幕上的画家
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// 创建一个立方体
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);
// 设置相机位置
camera.position.z = 5;
// 渲染场景
renderer.render(scene, camera);

这三个东西缺一不可,就像做饭需要锅、火、食材一样。没有场景,你的3D对象就没地方放;没有相机,你就看不到任何东西;没有渲染器,一切都只是内存里的数据,显示不出来。

刚开始我总是忘记设置相机位置,结果屏幕一片黑。后来才知道,相机默认在原点(0,0,0),如果你的物体也在原点,那相机就在物体内部了,当然看不到任何东西。就像是把眼睛贴在墙上看墙一样。

第二步:几何体和材质的奇妙世界(demo2-geometry)#

有了基础场景,接下来就是往里面放东西了。Three.js提供了各种现成的几何体,就像乐高积木一样,拿来就能用:

// 创建多个几何体
const geometries = [
new THREE.BoxGeometry(1, 1, 1), // 立方体
new THREE.SphereGeometry(0.8, 32, 16), // 球体
new THREE.CylinderGeometry(0.5, 0.5, 1, 32), // 圆柱体
new THREE.ConeGeometry(0.5, 1, 32), // 圆锥体
new THREE.TorusGeometry(0.6, 0.2, 16, 100), // 环形
new THREE.PlaneGeometry(1, 1) // 平面
];
// 创建不同的材质
const materials = [
new THREE.MeshBasicMaterial({ color: 0xff0000 }), // 红色基础材质
new THREE.MeshBasicMaterial({ color: 0x00ff00 }), // 绿色基础材质
new THREE.MeshBasicMaterial({ color: 0x0000ff }), // 蓝色基础材质
new THREE.MeshBasicMaterial({
color: 0xffff00,
wireframe: true
}), // 黄色线框材质
new THREE.MeshBasicMaterial({
color: 0xff00ff,
transparent: true,
opacity: 0.5
}), // 半透明材质
new THREE.MeshBasicMaterial({ color: 0x00ffff }) // 青色材质
];
// 创建网格并排列
geometries.forEach((geometry, index) => {
const mesh = new THREE.Mesh(geometry, materials[index]);
mesh.position.x = (index - 2.5) * 2; // 水平排列
scene.add(mesh);
});

材质就更有意思了,它决定了物体的”颜值”。从最简单的MeshBasicMaterial(不受光照影响,就像自带光环),到MeshLambertMaterial(漫反射材质,看起来比较自然),再到MeshPhongMaterial(有高光效果,看起来很有质感)。

刚开始我总是搞混这些材质,后来发现其实很好记:Basic就是”我不需要光照,我自己就很亮”;Lambert就是”我需要光照,但我不反光”;Phong就是”我不仅需要光照,我还要闪闪发光”。

第三步:让世界亮起来(demo3-lighting)#

没有光的3D世界就像是黑灯瞎火的房间,啥也看不清。Three.js的光照系统设计得很人性化:

// 创建一个需要光照的材质
const material = new THREE.MeshPhongMaterial({ color: 0x888888 });
// 创建几个物体
const sphere = new THREE.Mesh(
new THREE.SphereGeometry(1, 32, 16),
material
);
sphere.position.x = -3;
const cube = new THREE.Mesh(
new THREE.BoxGeometry(1, 1, 1),
material
);
const cylinder = new THREE.Mesh(
new THREE.CylinderGeometry(0.5, 0.5, 1, 32),
material
);
cylinder.position.x = 3;
scene.add(sphere, cube, cylinder);
// 添加环境光 - 基础照明
const ambientLight = new THREE.AmbientLight(0x404040, 0.3);
scene.add(ambientLight);
// 添加方向光 - 主光源
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(5, 5, 5);
scene.add(directionalLight);
// 添加点光源 - 补光
const pointLight = new THREE.PointLight(0xff4444, 0.5, 10);
pointLight.position.set(-5, 2, 0);
scene.add(pointLight);
// 添加聚光灯 - 特效光
const spotLight = new THREE.SpotLight(0x4444ff, 0.8, 20, Math.PI / 6);
spotLight.position.set(0, 8, 0);
spotLight.target = cube;
scene.add(spotLight);

刚开始调光照的时候,我经常把场景搞得要么黑得伸手不见五指,要么亮得像核爆现场。后来才明白,光照调节就像是摄影,需要主光、辅光、环境光的配合,才能营造出理想的效果。

一般来说,环境光提供基础亮度,方向光作为主光源,点光源和聚光灯用来补光和营造氛围。

第四步:给物体穿上”衣服”(demo4-textures)#

纹理就像是给3D物体穿衣服,让它们看起来更真实。最开始我以为纹理就是贴个图片上去,后来才发现这里面的门道多着呢。

// 创建纹理加载器
const textureLoader = new THREE.TextureLoader();
// 加载不同类型的纹理
const diffuseTexture = textureLoader.load('textures/brick_diffuse.jpg');
const normalTexture = textureLoader.load('textures/brick_normal.jpg');
const roughnessTexture = textureLoader.load('textures/brick_roughness.jpg');
// 设置纹理重复
diffuseTexture.wrapS = THREE.RepeatWrapping;
diffuseTexture.wrapT = THREE.RepeatWrapping;
diffuseTexture.repeat.set(2, 2);
// 创建使用纹理的材质
const texturedMaterial = new THREE.MeshStandardMaterial({
map: diffuseTexture, // 漫反射贴图
normalMap: normalTexture, // 法线贴图
roughnessMap: roughnessTexture // 粗糙度贴图
});
// 创建一个立方体应用纹理
const texturedCube = new THREE.Mesh(
new THREE.BoxGeometry(2, 2, 2),
texturedMaterial
);
scene.add(texturedCube);
// 创建地面
const groundGeometry = new THREE.PlaneGeometry(20, 20);
const groundTexture = textureLoader.load('textures/grass.jpg');
groundTexture.wrapS = THREE.RepeatWrapping;
groundTexture.wrapT = THREE.RepeatWrapping;
groundTexture.repeat.set(10, 10);
const groundMaterial = new THREE.MeshLambertMaterial({ map: groundTexture });
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -2;
scene.add(ground);

UV坐标系统是个让新手头疼的概念,简单来说就是告诉计算机”图片的哪一部分贴到物体的哪一部分”。就像是给球体贴地图一样,你得知道地图的哪个角对应球的哪个位置。

U和V分别对应纹理图片的水平和垂直方向,取值范围是0到1。(0,0)对应图片的左下角,(1,1)对应右上角。

第五步:让一切动起来(demo5-animation)#

静态的3D场景就像是博物馆里的展品,好看但缺乏生气。动画让3D世界活了过来。Three.js的动画系统基于requestAnimationFrame,这个API就像是浏览器的心跳,每次心跳都会调用你的动画函数。

// 创建多个动画对象
const animatedObjects = [];
// 旋转的立方体
const rotatingCube = new THREE.Mesh(
new THREE.BoxGeometry(1, 1, 1),
new THREE.MeshPhongMaterial({ color: 0xff0000 })
);
rotatingCube.position.x = -3;
scene.add(rotatingCube);
animatedObjects.push(rotatingCube);
// 跳跃的球体
const bouncingSphere = new THREE.Mesh(
new THREE.SphereGeometry(0.5, 32, 16),
new THREE.MeshPhongMaterial({ color: 0x00ff00 })
);
scene.add(bouncingSphere);
// 摆动的圆锥
const swingingCone = new THREE.Mesh(
new THREE.ConeGeometry(0.5, 1, 32),
new THREE.MeshPhongMaterial({ color: 0x0000ff })
);
swingingCone.position.x = 3;
scene.add(swingingCone);
// 动画循环
function animate() {
requestAnimationFrame(animate);
const time = Date.now() * 0.001;
// 旋转动画
rotatingCube.rotation.x += 0.01;
rotatingCube.rotation.y += 0.01;
// 跳跃动画
bouncingSphere.position.y = Math.abs(Math.sin(time * 3)) * 2;
// 摆动动画
swingingCone.rotation.z = Math.sin(time * 2) * 0.5;
// 相机环绕动画
camera.position.x = Math.cos(time * 0.5) * 8;
camera.position.z = Math.sin(time * 0.5) * 8;
camera.lookAt(0, 0, 0);
renderer.render(scene, camera);
}
animate();

刚开始我总是忘记调用renderer.render(),结果就是代码跑得好好的,但屏幕上啥变化都没有,就像是演员在台上卖力表演,但忘了开灯一样。

第六步:与用户互动(demo6-interaction)#

一个好的3D应用不能只是让用户”看”,还要让用户”玩”。鼠标控制、键盘交互、触摸操作,这些都让3D世界变得更有趣。

import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
// 添加轨道控制器
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
// 创建可交互的物体
const interactiveObjects = [];
for (let i = 0; i < 10; i++) {
const geometry = new THREE.BoxGeometry(
Math.random() * 0.5 + 0.5,
Math.random() * 0.5 + 0.5,
Math.random() * 0.5 + 0.5
);
const material = new THREE.MeshPhongMaterial({
color: Math.random() * 0xffffff
});
const cube = new THREE.Mesh(geometry, material);
cube.position.set(
(Math.random() - 0.5) * 10,
(Math.random() - 0.5) * 10,
(Math.random() - 0.5) * 10
);
scene.add(cube);
interactiveObjects.push(cube);
}
// 鼠标拾取
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
function onMouseClick(event) {
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(interactiveObjects);
if (intersects.length > 0) {
const clickedObject = intersects[0].object;
// 随机改变颜色
clickedObject.material.color.setHex(Math.random() * 0xffffff);
// 添加旋转动画
clickedObject.userData.spinning = true;
}
}
window.addEventListener('click', onMouseClick);
// 键盘控制
const keys = {};
window.addEventListener('keydown', (event) => keys[event.code] = true);
window.addEventListener('keyup', (event) => keys[event.code] = false);
function animate() {
requestAnimationFrame(animate);
// 键盘控制相机
if (keys['KeyW']) camera.position.z -= 0.1;
if (keys['KeyS']) camera.position.z += 0.1;
if (keys['KeyA']) camera.position.x -= 0.1;
if (keys['KeyD']) camera.position.x += 0.1;
// 更新被点击的物体
interactiveObjects.forEach(obj => {
if (obj.userData.spinning) {
obj.rotation.x += 0.02;
obj.rotation.y += 0.02;
}
});
controls.update();
renderer.render(scene, camera);
}

OrbitControls是个神器,几行代码就能让用户用鼠标控制相机,就像是给用户一个”上帝视角”的遥控器。

高级技巧和优化#

性能优化#

1. 几何体合并#

当场景中有大量相同的物体时,可以合并几何体来减少绘制调用:

import { BufferGeometryUtils } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
const geometries = [];
const material = new THREE.MeshBasicMaterial({ color: 0xff0000 });
// 创建100个立方体的几何体
for (let i = 0; i < 100; i++) {
const geometry = new THREE.BoxGeometry(0.1, 0.1, 0.1);
geometry.translate(
(Math.random() - 0.5) * 10,
(Math.random() - 0.5) * 10,
(Math.random() - 0.5) * 10
);
geometries.push(geometry);
}
// 合并所有几何体
const mergedGeometry = BufferGeometryUtils.mergeBufferGeometries(geometries);
const mesh = new THREE.Mesh(mergedGeometry, material);
scene.add(mesh);

2. 实例化渲染#

对于大量相同的物体,使用实例化渲染可以大幅提升性能:

const geometry = new THREE.BoxGeometry(0.1, 0.1, 0.1);
const material = new THREE.MeshBasicMaterial({ color: 0xff0000 });
// 创建实例化网格
const instancedMesh = new THREE.InstancedMesh(geometry, material, 1000);
// 设置每个实例的变换矩阵
const matrix = new THREE.Matrix4();
for (let i = 0; i < 1000; i++) {
matrix.setPosition(
(Math.random() - 0.5) * 20,
(Math.random() - 0.5) * 20,
(Math.random() - 0.5) * 20
);
instancedMesh.setMatrixAt(i, matrix);
}
scene.add(instancedMesh);

3. 视锥体剔除#

只渲染相机视野内的物体:

// Three.js默认开启视锥体剔除,但可以手动控制
object.frustumCulled = true; // 开启(默认)
object.frustumCulled = false; // 关闭

4. LOD(细节层次)#

根据距离显示不同精度的模型:

const lod = new THREE.LOD();
// 高精度模型(近距离)
const highDetail = new THREE.Mesh(
new THREE.SphereGeometry(1, 32, 16),
new THREE.MeshPhongMaterial({ color: 0xff0000 })
);
lod.addLevel(highDetail, 0);
// 中精度模型(中距离)
const mediumDetail = new THREE.Mesh(
new THREE.SphereGeometry(1, 16, 8),
new THREE.MeshPhongMaterial({ color: 0xff0000 })
);
lod.addLevel(mediumDetail, 10);
// 低精度模型(远距离)
const lowDetail = new THREE.Mesh(
new THREE.SphereGeometry(1, 8, 4),
new THREE.MeshPhongMaterial({ color: 0xff0000 })
);
lod.addLevel(lowDetail, 50);
scene.add(lod);

后处理效果#

Three.js支持各种后处理效果,让画面更加炫酷:

import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
import { BloomPass } from 'three/examples/jsm/postprocessing/BloomPass.js';
import { FilmPass } from 'three/examples/jsm/postprocessing/FilmPass.js';
// 创建效果合成器
const composer = new EffectComposer(renderer);
// 添加渲染通道
const renderPass = new RenderPass(scene, camera);
composer.addPass(renderPass);
// 添加辉光效果
const bloomPass = new BloomPass(1.5, 25, 4, 256);
composer.addPass(bloomPass);
// 添加胶片效果
const filmPass = new FilmPass(0.35, 0.025, 648, false);
composer.addPass(filmPass);
// 在动画循环中使用合成器渲染
function animate() {
requestAnimationFrame(animate);
composer.render();
}

踩过的坑和经验总结#

坑一:忘记设置相机位置#

刚开始的时候,我经常创建完相机就直接渲染,结果屏幕一片黑。后来才知道,相机默认在原点(0,0,0),如果你的物体也在原点,那相机就在物体内部了,当然看不到任何东西。就像是把眼睛贴在墙上看墙一样。

解决方案:

// 总是记得设置相机位置
camera.position.set(0, 0, 5);
// 或者
camera.position.z = 5;

坑二:材质和光照的配合#

用了MeshBasicMaterial却加了一堆光源,结果发现光照没效果;用了MeshLambertMaterial却没加光源,结果物体黑得像煤球。这就像是穿了件不合适的衣服去不合适的场合一样尴尬。

解决方案:

坑三:纹理加载的异步问题#

纹理加载是异步的,如果在纹理还没加载完就渲染,可能会看到白色的物体。解决办法是在纹理加载完成的回调函数里进行渲染,或者使用加载管理器。

解决方案:

// 方法一:使用回调
const texture = textureLoader.load('texture.jpg', function(texture) {
// 纹理加载完成后再渲染
renderer.render(scene, camera);
});
// 方法二:使用加载管理器
const loadingManager = new THREE.LoadingManager();
loadingManager.onLoad = function() {
console.log('所有资源加载完成');
animate(); // 开始动画循环
};
const textureLoader = new THREE.TextureLoader(loadingManager);

坑四:性能优化被忽视#

刚开始学的时候,我就像个熊孩子,往场景里塞各种东西,结果帧率掉得比股市还快。后来才学会合理使用几何体合并、实例化渲染等技术。

解决方案:

坑五:内存泄漏#

Three.js中的资源需要手动释放,否则会造成内存泄漏:

// 释放几何体
geometry.dispose();
// 释放材质
material.dispose();
// 释放纹理
texture.dispose();
// 从场景中移除物体
scene.remove(mesh);
// 释放渲染器
renderer.dispose();

坑六:坐标系混乱#

Three.js使用右手坐标系,Y轴向上,这跟一些其他3D软件不同。导入模型时可能需要调整:

// 如果模型方向不对,可能需要旋转
model.rotation.x = -Math.PI / 2; // 绕X轴旋转-90度

实际项目应用案例#

案例一:产品展示#

// 创建产品展示场景
class ProductViewer {
constructor(container) {
this.container = container;
this.init();
}
init() {
// 创建场景
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0xf0f0f0);
// 创建相机
this.camera = new THREE.PerspectiveCamera(
45,
this.container.clientWidth / this.container.clientHeight,
0.1,
1000
);
this.camera.position.set(5, 5, 5);
// 创建渲染器
this.renderer = new THREE.WebGLRenderer({ antialias: true });
this.renderer.setSize(this.container.clientWidth, this.container.clientHeight);
this.renderer.shadowMap.enabled = true;
this.container.appendChild(this.renderer.domElement);
// 添加控制器
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true;
// 添加光照
this.setupLighting();
// 加载模型
this.loadModel();
// 开始渲染
this.animate();
}
setupLighting() {
// 环境光
const ambientLight = new THREE.AmbientLight(0x404040, 0.6);
this.scene.add(ambientLight);
// 主光源
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(10, 10, 5);
directionalLight.castShadow = true;
this.scene.add(directionalLight);
}
loadModel() {
// 这里可以加载GLTF模型
// 暂时用一个立方体代替
const geometry = new THREE.BoxGeometry(2, 2, 2);
const material = new THREE.MeshStandardMaterial({
color: 0x888888,
metalness: 0.5,
roughness: 0.1
});
this.product = new THREE.Mesh(geometry, material);
this.product.castShadow = true;
this.scene.add(this.product);
// 添加地面
const groundGeometry = new THREE.PlaneGeometry(20, 20);
const groundMaterial = new THREE.MeshLambertMaterial({ color: 0xffffff });
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -1;
ground.receiveShadow = true;
this.scene.add(ground);
}
animate() {
requestAnimationFrame(() => this.animate());
this.controls.update();
this.renderer.render(this.scene, this.camera);
}
}
// 使用
const viewer = new ProductViewer(document.getElementById('product-container'));

案例二:数据可视化#

// 3D柱状图
class Chart3D {
constructor(container, data) {
this.container = container;
this.data = data;
this.init();
}
init() {
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
this.renderer = new THREE.WebGLRenderer();
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.container.appendChild(this.renderer.domElement);
this.camera.position.set(10, 10, 10);
this.camera.lookAt(0, 0, 0);
this.createChart();
this.animate();
}
createChart() {
const maxValue = Math.max(...this.data.map(d => d.value));
this.data.forEach((item, index) => {
const height = (item.value / maxValue) * 5;
const geometry = new THREE.BoxGeometry(0.8, height, 0.8);
const material = new THREE.MeshPhongMaterial({
color: new THREE.Color().setHSL(item.value / maxValue * 0.3, 0.8, 0.5)
});
const bar = new THREE.Mesh(geometry, material);
bar.position.set(index * 1.2, height / 2, 0);
this.scene.add(bar);
// 添加标签(这里简化处理)
const textGeometry = new THREE.TextGeometry(item.label, {
font: font, // 需要加载字体
size: 0.2,
height: 0.01
});
const textMaterial = new THREE.MeshBasicMaterial({ color: 0x000000 });
const textMesh = new THREE.Mesh(textGeometry, textMaterial);
textMesh.position.set(index * 1.2, -0.5, 0);
this.scene.add(textMesh);
});
// 添加光照
const ambientLight = new THREE.AmbientLight(0x404040, 0.6);
this.scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(5, 5, 5);
this.scene.add(directionalLight);
}
animate() {
requestAnimationFrame(() => this.animate());
this.renderer.render(this.scene, this.camera);
}
}

给新手的建议#

  1. 从简单开始:不要一上来就想做个《赛博朋克2077》,先把一个旋转的立方体做好。

  2. 多看文档:Three.js的官方文档写得很好,例子也很丰富,遇到问题先查文档。

  3. 理解概念:不要只是复制粘贴代码,要理解场景、相机、渲染器这些基本概念。

  4. 多做实验:改改参数,看看效果,这比看十篇教程都有用。

  5. 学会调试:浏览器的开发者工具是你的好朋友,学会看控制台的错误信息。

  6. 关注性能:从一开始就要有性能意识,不要等到卡顿了才想起来优化。

  7. 学习数学基础:虽然Three.js封装了很多数学运算,但了解基本的向量、矩阵知识会让你走得更远。

  8. 多看优秀案例:Three.js官网的例子、CodePen上的作品,都是很好的学习资源。

进阶学习路径#

阶段一:基础掌握(1-2个月)#

阶段二:深入理解(2-3个月)#

阶段三:高级应用(3-6个月)#

阶段四:专业开发(6个月以上)#

结语#

WebGL和Three.js就像是打开了一扇通往3D世界的大门。虽然刚开始可能会被各种概念搞得晕头转向,但一旦入了门,你会发现这个世界真的很精彩。

从最简单的旋转立方体,到复杂的3D场景,每一步都是一个小小的成就。就像学骑自行车一样,刚开始可能会摔几跤,但一旦掌握了平衡,就能自由地在3D的世界里驰骋了。

现在回头看看我的学习历程,虽然踩了不少坑,但每个坑都让我对3D图形有了更深的理解。如果你也对WebGL和Three.js感兴趣,不妨从一个简单的demo开始,相信我,这会是一段很有趣的旅程。

记住,学习3D编程不是一蹴而就的事情,需要耐心和坚持。但当你看到自己创造的3D世界在浏览器里运行时,那种成就感是无法言喻的。

毕竟,谁不想在浏览器里创造一个属于自己的3D世界呢