0先建立正确的心智模型
学 three.js 最容易踩的坑:把它当成「一个有很多 API 的黑盒」去背。正确姿势是先搞清 WebGL 在做什么,再看 three.js 帮你封装掉了哪一步。
new Geometry()、new Material()、scene.add() 都是在建立或修改对象。直到调用 renderer.render(scene, camera),渲染器才会按当前数据提交绘制。下面的「过程」会明确区分内部状态与真实画面;原理图均为本站重绘的教学示意,不是运行截图。先补齐 6 个基础词
- 顶点 / 三角形
- 顶点是一组位置、法线、UV 等属性。三个顶点组成一个三角形,许多三角形拼成表面。
- 几何体 / 材质 / 网格
- Geometry 定义形状,Material 定义如何着色;Mesh 把两者组合成可放进场景的对象。
- 法线 normal
- 描述表面朝向的向量,光照据此计算明暗。它不是顶点的位置,也不会自动变成可见的线。
- 弧度 / 角度
- 对象 rotation 用弧度:π/2 = 90°;PerspectiveCamera 的 fov 则使用度。
- 单位 / 坐标
- Three.js 不强制单位;项目可约定 1 单位 = 1 米。物体位置与相机裁剪距离必须使用一致尺度。
- CSS 像素 / 绘图缓冲
- 画布显示尺寸与内部像素数不同。800×400 的画布在 DPR=2 时通常渲染 1600×800,即 4 倍像素。
| 对比项 | WebGL | three.js |
|---|---|---|
| 本质 | 基于 OpenGL ES、为浏览器设计的图形 API;WebGL 2 对应 ES 3.0 的能力体系 | 3D 库;本文用 WebGLRenderer,也有面向 WebGPU 的渲染器 |
| 能画什么 | 只有点、线、三角形 | 模型、灯光、阴影、材质、骨骼动画… |
| 准备工作 | 手动编译着色器、上传缓冲、设置属性与状态 | 用几何体、材质和对象表达场景,由渲染器管理底层步骤 |
| 学习价值 | 理解 GPU 如何工作、性能瓶颈在哪 | 快速做出能看的东西 |
| 什么时候必须懂底层 | 自定义着色器、粒子/GPU 计算、诡异的渲染 bug、极致性能优化 | |
1WebGL 基础:GPU 到底替你做了什么
这一章的目标是让你看到 three.js 的 API 时,脑子里能浮现出它背后对应的 GPU 操作。
1.1 一句话理解 WebGL
WebGL 是一套由 JS 配置图形管线并提交绘制命令的接口。本教程的图形管线包含两个可编程阶段:顶点着色器和片元着色器,它们用 GLSL 编写。GPU 会并行处理大量顶点和片元;普通 JS 负责组织数据和控制绘制。
- 顶点着色器(Vertex Shader):处理输入顶点,写出齐次裁剪坐标
gl_Position;透视除法和视口变换在后续阶段完成。 - 片元着色器(Fragment Shader):为光栅化产生的片元计算候选颜色。片元可能被深度测试丢弃;多重采样和透明混合时,片元与最终像素也不一一对应。
- CPU(你的 JS)负责:上传数据、设置状态、发出
drawArrays/drawElements命令。
1.2 从顶点到片元的渲染管线
1.3 最小实例:原生 WebGL 画一个三角形
先感受一下「裸写」的成本。完整流程只有 5 步,每步都对应一组 API:
| 步骤 | 做什么 | 用到的 API |
|---|---|---|
| 1 | 拿到画布上下文 | canvas.getContext('webgl2') |
| 2 | 写 GLSL 并编译链接 | createShader · shaderSource · compileShader · createProgram · linkProgram · useProgram |
| 3 | 顶点数据传进显存 | createBuffer · bindBuffer · bufferData |
| 4 | 告诉 GPU 数据的摆放格式 | getAttribLocation · enableVertexAttribArray · vertexAttribPointer |
| 5 | 发命令开画 | clear · drawArrays / drawElements |
核心代码(截取最关键的三段,完整可运行版见下方 Demo):
// 顶点着色器:把 2D 顶点直接当裁剪空间坐标输出(顺手做了个旋转)
const vsSrc = `#version 300 es
in vec2 aPos; // 每个顶点各一份 → attribute
in vec3 aColor;
uniform float uAngle; // 全场共享 → uniform
out vec3 vColor; // 传给片元着色器,自动插值
void main() {
vColor = aColor;
float c = cos(uAngle), s = sin(uAngle);
gl_Position = vec4(aPos.x * c - aPos.y * s, aPos.x * s + aPos.y * c, 0.0, 1.0);
}`;
// 片元着色器:决定每个像素的颜色
const fsSrc = `#version 300 es
precision highp float;
in vec3 vColor;
out vec4 outColor;
void main() { outColor = vec4(vColor, 1.0); }`;
GLSL ES 3.00:in = 输入,out = 输出,uniform = 外部传入的全局变量
// 顶点数据:x, y, r, g, b 交错了塞进一个 Float32Array(CPU 侧准备)
const data = new Float32Array([
0.0, 0.6, 1.0, 0.2, 0.3,
-0.6, -0.4, 0.2, 0.6, 1.0,
0.6, -0.4, 1.0, 0.8, 0.2,
]);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW); // 上传到 GPU
// 描述内存布局:步长 20 字节,aPos 在 0 处,aColor 在 8 字节处
gl.vertexAttribPointer(aPosLoc, 2, gl.FLOAT, false, 20, 0);
gl.vertexAttribPointer(aColorLoc, 3, gl.FLOAT, false, 20, 8);
gl.drawArrays(gl.TRIANGLES, 0, 3); // 画 1 个三角形
每个顶点含 5 个 float,每个 float 占 4 字节,因此 stride = 20;颜色从第 3 个 float 开始,offset = 8。交错布局有利于部分访问模式,但是否更快需要测量。
- GLSL 源码 → programvsSrc 与 fsSrc 只是字符串;编译、链接、useProgram 后 GPU 才知道使用哪套计算规则,此时仍没图形。
- 数组 → 缓冲三个顶点 A=(0,0.6)、B=(−0.6,−0.4)、C=(0.6,−0.4)。w=1 时裁剪坐标除以 w 不变;上传与属性指针只准备输入。
- drawArrays → 三角形读取 3 个顶点,按 uAngle 旋转;gl.TRIANGLES 将它们组成一个三角形。首次角度为 0 时,A 在上、B 在左下、C 在右下。
- 插值 → 填色区域三角形内部的 RGB 随顶点颜色插值,片元着色器写出颜色;Demo 每帧改变角度并重画,使用正方形 viewport 保持比例。
useProgram()、属性位置查询、enableVertexAttribArray()、uniform 和视口设置,完整流程见 Demo 1 源码。Three.js 简化了这些工作,但仍需要初始化场景、相机和渲染器,不能只用三行独立运行。1.4 坐标系与三大矩阵
3D 最劝退的部分其实是矩阵。但你要理解的只有一条链条:顶点从「模型自己的坐标」一路被变换到「屏幕像素」。
| 空间 | 关键矩阵 | 你在 three.js 里怎么改它 |
|---|---|---|
| 局部 → 世界 | modelMatrix | mesh.position.set() / rotation / scale |
| 世界 → 相机 | viewMatrix | camera.position / camera.lookAt() / OrbitControls |
| 相机 → 裁剪 | projectionMatrix | 透视相机:fov aspect near far;正交相机:left/right/top/bottom |
modelViewMatrix = viewMatrix × matrixWorld,而 matrixWorld = 父节点 matrixWorld × 本地 matrix。Blender 默认 Z-up,Maya 默认 Y-up(可配置);优先让导出器和加载器转换,不要看到模型就重复旋转。r184 的 FBXLoader 轴向变化见迁移指南。用一个数值例子理解局部坐标:盒子上的局部顶点 (0.5,0.5,0.5),在只有平移 cube.position.x = 2、没有父级变换时,世界坐标为 (2.5,0.5,0.5)。相机移到别处不会改变这个世界坐标,只会改变它在相机空间和屏幕上的位置。
1.5 WebGL 是状态机:常见资源对象
WebGL 的 API 几乎全是「先 bind 一个东西,再操作它」。理解下面这些对象,看 three.js 源码或报错信息就不慌了。
| 对象 | 作用 | three.js 对应 |
|---|---|---|
| Buffer (VBO) | 存放顶点数据的显存块 | BufferAttribute |
| VAO | 记录「顶点属性怎么读」的快照,减少重复设置 | WebGLBindingStates(内部自动管理) |
| Texture | 贴图,含过滤/环绕方式 | Texture / DataTexture / CubeTexture |
| Program | 编译链接后的着色器程序 | ShaderMaterial / 内置材质编译产物 |
| Uniform | 一次 draw call 内不变的全局变量 | material.uniforms(自定义着色器时) |
| Framebuffer | 离屏渲染目标,可挂颜色/深度纹理 | WebGLRenderTarget |
先定位瓶颈,再优化:大量 draw call 与状态切换可能使 CPU 忙于提交命令;大量顶点、昂贵片元着色器、高分辨率、透明叠加和阴影也可能使 GPU 成为瓶颈。renderer.info 可查看调用数、三角形数和资源数量,但它不是 GPU 耗时分析器。
2three.js 核心:从第一个立方体到完整场景
这一章是主线。本文固定使用本站保存的 three@0.186.1(r186),不把这个版本称为「最新版」。所有完整 Demo 使用同一份本地库,避免主库与 addons 混用版本。
2.1 环境搭建(两种方式)
官方推荐 npm + 构建工具(Vite);学习和做 Demo 时用 importmap + CDN 最省事,不需要任何安装。
方案 A · npm + Vite(项目用)
npm install three@0.186.1
npm install --save-dev vite
npx vite
然后在 main.js 里 import * as THREE from 'three';,addons 用 import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
方案 B · importmap + CDN(学习用)
<script type="importmap">
{ "imports": {
"three": "https://unpkg.com/three@0.186.1/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.186.1/examples/jsm/"
} }
</script>
importmap 把裸模块名映射到具体文件,浏览器原生支持,不需要打包工具。学习阶段最省事。
index.html,在 body 中放入 <script type="module" src="/main.js"></script>。方案 B 的 importmap 必须位于使用它的 module 脚本之前。两者都通过 HTTP 服务访问,避免直接双击 file://;CDN 方案首次访问需要网络。本站 Demo 使用本地 /vendor/three-0.186.1/,无需外部 CDN;首次打开页面仍需能访问本站或本地服务。PCFSoftShadowMap | WebGLRenderer 在 r182 弃用它;r186 的移除说明针对 WebGPURenderer。本文统一用 PCFShadowMap,不要把两个后端的迁移说明混用。 |
Clock | 已废弃,改用核心类 Timer(timer.update() → getDelta()) |
PostProcessing | WebGPU 侧已改名 RenderPipeline(WebGL 的 EffectComposer 不变) |
| 模块导入 | 本教程使用 ES Module;浏览器通过 importmap 或构建工具解析 three。不要混用旧版全局 THREE 脚本和不同版本的 addons。 |
| WebGPU | 可用性取决于浏览器、系统、驱动和安全上下文。运行时检测并处理初始化失败,不能只根据浏览器名称判断。 |
2.2 三大件 + 场景图
任何 three.js 程序都由三样东西撑起来:Scene(装东西的容器)、Camera(从哪看)、Renderer(画出来)。而 Scene 里的对象是一棵树——这棵树叫场景图(Scenegraph)。
- 创建 Scene建立根容器,屏幕不会因此出现物体。
- 添加 Mesh / Group每个对象只有一个父节点;Group 用来整体移动、旋转一组对象。
- 设置 Camera确定观察位置和投影,不必加入 Scene;若相机挂着灯等子对象,则要让这些子对象进入被遍历的树。
- 调用 renderRenderer 读取场景与相机,把可见对象绘制到画布。
2.3 Hello Cube:一块空画布怎样变成立方体
把下面 6 段代码依次放进同一个 main.js;先按 2.1 搭好入口,并设置 body { margin: 0; }。下方 Demo 与这些代码使用相同的相机、几何体、材质和灯光,额外提供步骤切换。初始立方体居中,无网格地面,也没有默认阴影。
步骤 1 / 建立场景与观察位置
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0f172a);
const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 100);
camera.position.set(2.2, 1.8, 3);
camera.lookAt(0, 0, 0);
lookAt() 只改变观察方向,不画相机模型。60 是垂直视场角,0.1 和 100 是裁剪距离。
步骤 2 / 创建画布,先渲染一次空场景
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);
renderer.render(scene, camera);
setSize() 决定画布大小;DPR 上限 2 是清晰度与开销的折中;appendChild() 将 canvas 放入页面;render() 清出背景。场景还没有可画的几何体。
步骤 3 / 准备形状和材质,但先不加入场景
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial({
color: 0x6366f1, roughness: 0.35, metalness: 0
});
const cube = new THREE.Mesh(geometry, material);
scene.add(cube),下一次渲染也不会遍历到它。
步骤 4 / 加入场景,观察没有照明的物体
scene.add(cube);
renderer.render(scene, camera);
scene.add() 本身不会刷新画面;第二行才提交绘制。黑色轮廓也与背景对比有关。
步骤 5 / 让表面得到照明
scene.add(new THREE.HemisphereLight(0xffffff, 0x223344, 1.5));
const sun = new THREE.DirectionalLight(0xffffff, 2.2);
sun.position.set(3, 4, 2);
scene.add(sun);
renderer.render(scene, camera);

步骤 6 / 持续刷新,才会看到旋转与视角变化
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 0, 0);
controls.update();
const timer = new THREE.Timer();
timer.connect(document); // 启用页面可见性感知
renderer.setAnimationLoop(() => {
timer.update();
cube.rotation.y += timer.getDelta() * 0.6;
renderer.render(scene, camera);
});
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
delta 的单位是秒,60 FPS 时每帧约增加 0.01 弧度。resize 只改投影与画布大小,不改变盒子的世界尺寸。
delta;也可以直接用 elapsed 计算绝对角度。固定每帧增量会受帧率影响;
② 窗口 resize 时必做三件事:改 camera.aspect → updateProjectionMatrix() → renderer.setSize();
③ 用 renderer.setAnimationLoop() 而不是手写 rAF,WebXR 环境下它能自动接管。2.4 几何体 Geometry:顶点是怎么组织的
常用几何体都建立在 BufferGeometry 上:用类型化数组保存顶点属性,并可选用 index 复用顶点。position 常用浮点数,索引通常使用无符号整数;不是所有缓冲都是浮点数组。
用 4 个顶点拼一块正方形,可把下面代码作为独立片段放进已有场景。它与图 5 的编号完全对应,采用不依赖灯光的 Basic 材质。
const quadGeometry = new THREE.BufferGeometry();
quadGeometry.setAttribute('position', new THREE.Float32BufferAttribute([
-0.5, -0.5, 0, 0.5, -0.5, 0, // 0、1:左下、右下
0.5, 0.5, 0, -0.5, 0.5, 0 // 2、3:右上、左上
], 3));
quadGeometry.setIndex([0, 1, 2, 0, 2, 3]);
const quad = new THREE.Mesh(quadGeometry,
new THREE.MeshBasicMaterial({ color: 0x5273d2, side: THREE.DoubleSide }));
scene.add(quad);
renderer.render(scene, camera);
| 常用内置几何体 | 说明 |
|---|---|
BoxGeometry | 盒子。做建筑、体块示意最常用 |
SphereGeometry | 球。widthSegments / heightSegments 控制横向 / 纵向分段数;分段越多,轮廓越圆滑,顶点也越多 |
PlaneGeometry | 平面,默认在 XY 面上、法线朝 +Z,做地面要转 −90° |
CylinderGeometry / ConeGeometry / TorusGeometry | 圆柱 / 圆锥 / 圆环 |
TubeGeometry / ExtrudeGeometry | 沿曲线生成管道 / 2D 形状挤出成 3D |
BufferGeometry(自建) | 手写顶点数组,配合 setAttribute() |
2.5 材质与光照
材质 = 「表面怎么反射光」的算法。three.js 的材质可以按「是否受光照影响」和「真实程度」分成几档:
| 材质 | 受光照 | 什么时候用 |
|---|---|---|
MeshBasicMaterial | 否 | 不依赖灯光,适合排查照明问题;仍受裁剪、遮挡、透明度和 visible 等设置影响 |
MeshLambertMaterial | 是(廉价) | 性能优先的低端设备,无高光 |
MeshPhongMaterial | 是 | 有高光 shininess,塑料感,非真实感项目 |
MeshStandardMaterial | 是(PBR) | 默认首选。metalness / roughness 两个参数走天下 |
MeshPhysicalMaterial | 是(PBR+) | 玻璃、车漆、清漆、透射等;附加效果会增加渲染开销 |
MeshNormalMaterial / MeshDepthMaterial | 否 | 调试法线/深度 |
ShaderMaterial / RawShaderMaterial | 自定义 | 完全自己写 GLSL(见 3.1) |
SpriteMaterial / PointsMaterial / LineBasicMaterial | — | 精灵、点云、线 |
PBR 的两个核心参数
| 光源 | 特性 |
|---|---|
AmbientLight | 无方向的环境照明项,可以单独使用,但缺少方向性的明暗层次;不投射阴影 |
HemisphereLight | 天空色 + 地面色,模拟环境光。打底首选 |
DirectionalLight | 平行光(太阳)。支持阴影;PointLight 与 SpotLight 也支持,注意范围与成本 |
PointLight | 点光源(灯泡)。距离衰减,阴影开销大( cube shadow ) |
SpotLight | 聚光灯,带角度与边缘虚化 |
环境贴图 envMap | 提供来自周围场景的照明与反射。可用 PMREMGenerator + RoomEnvironment 生成;没有环境贴图时,金属仍可反射直接光,但外观通常缺少环境细节 |
2.6 纹理与 UV
纹理是供着色器采样的数据,可以保存颜色,也可以保存法线、粗糙度等信息。UV 是顶点上的二维采样坐标,通常用 0~1 对应一张图,但允许超出这个范围;越界结果由 wrapS / wrapT 决定。
下面用程序生成棋盘贴图,没有缺失的图片文件,也无需等待网络。前提:已有 2.3 的 scene、renderer、cube,且正在持续渲染。
const tile = document.createElement('canvas');
tile.width = tile.height = 128;
const ctx = tile.getContext('2d');
ctx.fillStyle = '#eef2ff'; ctx.fillRect(0, 0, 128, 128);
ctx.fillStyle = '#5273d2';
ctx.fillRect(0, 0, 64, 64); ctx.fillRect(64, 64, 64, 64);
const tex = new THREE.CanvasTexture(tile);
tex.colorSpace = THREE.SRGBColorSpace; // 这里保存的是 sRGB 颜色
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(4, 4);
tex.anisotropy = Math.min(8, renderer.capabilities.getMaxAnisotropy());
cube.material.color.set(0xffffff); // 避免原来的紫蓝色与贴图相乘
cube.material.map = tex;
cube.material.needsUpdate = true; // 从无 map 变为有 map,需要更新着色器配置
- 画棋盘先在独立的 2D canvas 上生成两色图案,3D 画布还没变。
- 建立 Texture将图案作为纹理来源;颜色图标记 sRGB,法线、粗糙度、AO 等数据图通常保持 NoColorSpace。
- 设置平铺repeat=(4,4) 将完整图案重复 4×4 次;BoxGeometry 每个面的默认 UV 都覆盖整张纹理。
- 指定 map 并重画下一帧出现带棋盘格的立方体,几何轮廓不变,灯光仍影响表面明暗。
texture.channel 选择属性:0 对应 uv,1 对应 uv1。AO 并不固定要求 uv1;只有 aoTex.channel = 1 时才需要几何体提供 uv1。旧版示例和部分 API 注释仍写「必须第二套 UV」,不能照搬。图片上下方向还受加载路径与 flipY 影响,不能把「图片左下角」当成统一约定。2.7 相机:你能看见的那一块「视锥」
相机定义了「世界的哪一块会被画出来」。透视相机的可视范围是一个被截断的金字塔——视锥体(Frustum),视锥剔除(Frustum Culling)的名字就来自这里。
| 相机 | 特点 | 用途 |
|---|---|---|
PerspectiveCamera | 近大远小,符合人眼 | 绝大多数场景 |
OrthographicCamera | 无透视,尺寸恒定 | 等轴测、2.5D、CAD、UI 正交预览 |
ArrayCamera | 多相机拼接 | VR / 多屏 |
CubeCamera | 6 面渲染成环境贴图 | 实时反射(镜子、车漆) |
2.8 阴影:Shadow Map 的原理与调优
本文使用 Shadow Map 阴影:先从光源视角记录可见表面的深度,再将相机视角的待着色表面变换到相同光源坐标系中比较深度。被更近的表面遮挡时,削弱该灯的直接光照;环境光不会因此全部消失。
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFShadowMap; // 本文 WebGL 示例统一使用 PCF
sun.castShadow = true;
sun.shadow.mapSize.set(1024, 1024); // 越大越清晰越慢,取 2 的幂
// 覆盖相关投影物与接收区域,范围越紧凑,单位面积的阴影精度越高
const c = sun.shadow.camera;
c.left = -6; c.right = 6; c.top = 6; c.bottom = -6; c.near = 1; c.far = 20;
c.updateProjectionMatrix();
sun.shadow.bias = -0.0005; // 消除自身条纹(acne),别调太大否则漏光
cube.castShadow = true;
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(12, 12),
new THREE.MeshStandardMaterial({ color: 0x94a3b8, roughness: 0.9 })
);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -0.5;
ground.receiveShadow = true;
scene.add(ground);
接在 2.3 后运行,使用已有的 sun 和 cube。参数是本场景起点,不是所有模型都通用的 bias;静态场景还需再调用一次 render。
- 开阴影功能仅开 renderer 开关,画面通常还不会出现阴影。
- 灯和盒子都 castShadow允许从 sun 的视角生成盒子的深度。没有接收面,仍看不到地面投影。
- 添加水平地面Plane 默认在 XY 面;转 −π/2 后朝 +Y,放到 y=−0.5 与盒子底部相接。
- 地面 receiveShadow下一帧在背光一侧出现投影;扩大 shadow.camera 范围会覆盖更多地面,但降低细节精度。
2.9 动画循环:什么时候该画,什么时候别画
| 方式 | 适用场景 | 写法 |
|---|---|---|
| 持续渲染 | 有动画/着色器时间变化 | renderer.setAnimationLoop(fn) |
| 按需渲染 | 静态展示、编辑器、省电 | 只在 controls 的 'change' 事件或数据变化时 render() |
// 替换 2.3 的动画循环;本例禁用阻尼与自动旋转
renderer.setAnimationLoop(null);
controls.enableDamping = false;
controls.autoRotate = false;
const render = () => renderer.render(scene, camera);
controls.addEventListener('change', render);
addEventListener('resize', render); // 注册在原有 resize 尺寸更新监听器之后
render(); // 首帧必须主动绘制
// 加载贴图完成、改材质、改对象位置后也要调用 render()
timer.connect(document),每帧 update() 后读取以秒为单位的 getDelta() / getElapsed();销毁时 timer.dispose()。只 new Timer 而不 connect,不会自动启用 Page Visibility 保护。setTimescale() 可缩放逻辑时间,极端长帧的模拟仍可能需要限制步长。2.10 交互:相机控制与鼠标拾取
OrbitControls 解决「看」;Raycaster 解决「点中谁」——原理是从相机发射一条射线,与场景中物体求交。
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
const pickables = [cube]; // 本例仅拾取这个单材质 Mesh
renderer.domElement.addEventListener('click', (e) => {
const rect = renderer.domElement.getBoundingClientRect();
pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
scene.updateMatrixWorld(true);
camera.updateMatrixWorld(true);
raycaster.setFromCamera(pointer, camera);
const hits = raycaster.intersectObjects(pickables, true);
if (hits.length) {
hits[0].object.material.color.set(0xff0000);
renderer.render(scene, camera);
}
});
- 点击位置 → NDC画布左上是 (−1,+1),中心是 (0,0),右下是 (+1,−1)。按画布矩形换算,嵌入文章也不会偏移。
- 相机 → 射线 → 交点从点击位置构建射线,hits 按距离排序,取最近命中的物体。
- 改色 → 再渲染命中盒子后变红;点空白则保持不变。复杂模型可能使用材质数组或共享材质,需另外处理。
intersectObjects(scene.children) 全场景遍历)。3进阶:着色器、后处理与性能
到这里你已经能做出正常东西了。这一章解决「我想做得更酷」和「为什么卡」。
3.1 自定义着色器 ShaderMaterial
three.js 会自动给你注入一批内置变量,所以写着色器时不用自己声明它们:
| 内置量 | 含义 |
|---|---|
position normal uv | 顶点属性(来自 BufferGeometry) |
modelMatrix modelViewMatrix projectionMatrix normalMatrix | 对象、视图、投影与法线变换。normalMatrix 是 modelViewMatrix 左上 3×3 部分的逆转置,用于正确变换法线 |
cameraPosition | 相机世界坐标,做菲涅尔/高光要用 |
const uniforms = { uTime: { value: 0 } };
const material = new THREE.ShaderMaterial({
uniforms,
vertexShader: /* glsl */`
uniform float uTime;
varying float vHeight;
void main() {
vec3 p = position;
float h = sin(p.x * 3.0 + uTime) * 0.25 + cos(p.y * 3.0 + uTime * 0.8) * 0.25;
p.z += h; // GPU 上计算位移,避免逐帧在 JS 改顶点数组
vHeight = h;
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
}`,
fragmentShader: /* glsl */`
varying float vHeight;
void main() {
vec3 col = mix(vec3(0.24,0.29,0.95), vec3(0.96,0.45,0.85), vHeight + 0.5);
gl_FragColor = vec4(col, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}`
});
const wave = new THREE.Mesh(new THREE.PlaneGeometry(4, 4, 96, 96), material);
material.side = THREE.DoubleSide;
wave.rotation.x = -Math.PI / 2.2;
scene.add(wave);
// 使用 2.3 的 Timer;每帧 update() 后、render() 前:
// uniforms.uTime.value = timer.getElapsed();
- 复制 positionp 是当前顶点的局部坐标,JS 中的原始顶点数组不会被这个着色器修改。
- 算高度 h两个振幅 0.25 的波叠加,h 的理论范围为 [−0.5,0.5];p.z += h 沿局部 Z 位移。整张平面旋转后,方向随之改变。
- 投影并插值矩阵把新位置投影到画面;vHeight 随三角形插值到片元。用 h+0.5 映射到 [0,1],避免 mix 外插。
- 推进时间每帧更新 uTime 并渲染才会动。Demo 额外叠加了 UV 网格。此例不计算物理光照,也不自动更新 CPU 包围体、射线拾取形状与阴影位移。
3.2 后处理:把画面再加工一遍
原理就是图 2 的 ⑥:先把场景渲到一张纹理(RenderTarget),再对这张纹理跑一个全屏着色器(Bloom 发光、景深、抗锯齿、色彩分级…)。
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
composer.addPass(new UnrealBloomPass(new THREE.Vector2(innerWidth, innerHeight), 0.8, 0.4, 0.85));
composer.addPass(new OutputPass()); // 最后进行色调映射与输出颜色转换
// 在已有动画循环中,将 renderer.render(scene, camera) 换成:
// composer.render();
// resize 时,在相机和 renderer 更新尺寸后再执行:
// composer.setSize(innerWidth, innerHeight);
WebGPU 侧使用 RenderPipeline 和节点效果,不能直接复用 WebGL 的 EffectComposer passes。
3.3 性能优化:减少提交,也要控制像素成本
| 手段 | 解决什么 | 代价 |
|---|---|---|
InstancedMesh | 共享几何体与材质的重复物体,通常每材质组、每 pass 一次实例化绘制 | 变换与颜色可逐实例设置;自定义属性需额外设计。阴影、多材质等会增加调用 |
BatchedMesh | 批量组织同材质、不同几何体的对象 | 容量、排序、透明度与剔除需评估,不能承诺所有设备都只有一次底层调用 |
合并几何体 mergeGeometries | 静态大场景 | 合并后无法单独控制子物体 |
| 纹理图集 / 共享材质 | 减少材质切换 | UV 要重新排布 |
LOD / 视锥剔除 | 远处用低模 | 多套模型资源 |
setPixelRatio(min(dpr,2)) | 高分屏最直接的提帧手段 | 略糊 |
// 结束整个示例时:
renderer.setAnimationLoop(null);
controls.dispose();
timer.dispose();
scene.remove(cube);
cube.geometry.dispose();
cube.material.map?.dispose(); // 若执行了 2.6,纹理也是本例独占
cube.material.dispose();
renderer.dispose();
3.4 加载 glTF 模型
glTF 是面向运行时传输的 3D 资产格式,可包含网格、材质、层级和动画。下面是接入片段:需要自行准备 /models/robot.glb;只有使用 Draco 网格压缩的文件才需要匹配的解码器。本站没有附带这个模型。
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
let mixer;
const draco = new DRACOLoader().setDecoderPath('/draco/'); // 自行放置 Draco 解码器
const loader = new GLTFLoader().setDRACOLoader(draco);
loader.load('/models/robot.glb', (gltf) => {
const model = gltf.scene;
model.traverse(o => { if (o.isMesh) o.castShadow = true; });
scene.add(model);
if (gltf.animations.length) { // 可能是骨骼或其他属性动画
mixer = new THREE.AnimationMixer(model);
mixer.clipAction(gltf.animations[0]).play();
}
}, (p) => {
if (p.lengthComputable && p.total > 0) console.log('进度', p.loaded / p.total);
}, (error) => console.error('模型加载失败', error));
在已有循环的 timer.update() 之后、最终渲染之前执行 mixer?.update(timer.getDelta())。若按需渲染,加载回调中应请求重画;动画播放期间需要连续帧。结束使用 Draco loader 后调用 draco.dispose()。
- 发请求并解码等待期间场景不会自动出现模型,下载进度也不等于解码完成。
- 加入 gltf.scene下一次 render 才出现模型;若尺寸、相机范围或灯光不合适,加入成功也可能看不到。
- 播放动画play() 激活轨道,mixer.update(delta) 推进姿态,render 显示新的姿态。
3.5 WebGPU 与 TSL:现在该学吗
| 项 | 说明 |
|---|---|
| 可用性 | 需要检查浏览器、系统、驱动与安全上下文。WebGPURenderer 优先尝试 WebGPU,不可用时可使用 WebGL 2 后端;仍应处理初始化失败 |
| 怎么用 | import { WebGPURenderer } from 'three/webgpu';,初始化是异步的(await renderer.init()) |
| TSL | Three Shading Language,用 JS 组合着色节点,面向 WebGPURenderer 的 WebGPU / WebGL 2 后端;不等于传统 WebGLRenderer 支持 NodeMaterial,也不意味着计算功能能在两个后端完全等价运行 |
| 学习顺序 | 先掌握场景、变换、材质和渲染循环,再根据项目兼容性选择后端。ShaderMaterial 的 GLSL 不能直接搬到 WebGPU 后端 |
4学习路线、资源与避坑清单
4.1 四周路线图(每天 1~2 小时)
| 阶段 | 目标 | 做什么 |
|---|---|---|
| 第 1 周 | 跑通 + 建立直觉 | 照官方手册 Fundamentals / Primitives / Scenegraph / Materials / Lights 抄一遍;把 Demo 2、3 的参数逐个改坏再改回来,观察变化 |
| 第 2 周 | 场景搭建能力 | 做一个「室内一角」:贴图 + 灯光 + 阴影 + OrbitControls + 响应式 |
| 第 3 周 | 动起来 | Timer 动画、加载一个 glTF 模型并播放骨骼动画、Raycaster 点击高亮 |
| 第 4 周 | 进阶 | 写一个 ShaderMaterial(参照 Demo 4)、加 Bloom 后处理、用 InstancedMesh 铺 5000 棵树测帧率 |
4.2 官方与高质量资源
| 资源 | 说明 |
|---|---|
| three.js 官方手册 ↗ | 场景图、材质、灯光、相机与响应式的系统学习入口 |
| three.js 官方示例 ↗ | 运行示例并查看源码,注意与本文固定版本的差异 |
| MDN 绘制基础 ↗ | 上下文、缓冲、着色器与 draw 调用的关系 |
| WebGL Fundamentals ↗ | 矩阵、纹理与光照的交互教学 |
| Migration Guide ↗ | 核对版本变更,并分清 WebGLRenderer / WebGPURenderer 的适用范围 |
4.3 避坑清单(照着排查能省半天)
| 现象 | 优先检查 |
|---|---|
| 画面全黑 | PBR 材质没加灯 / 相机在物体内部 / near-far 范围不对 / 材质设了黑色 |
| 模型看不见但没报错 | 缩放差几个数量级(Blender 导出单位)、被 far 裁掉、法线反了(试 side: DoubleSide) |
| 颜色发灰/发白 | 颜色贴图没设 SRGBColorSpace,或 renderer 的 outputColorSpace 被改过 |
| 阴影条纹 / 断层 | shadow.bias 没调、阴影相机范围太大导致分辨率不够 |
| 表面闪烁 | 共面或极近表面的 z-fighting;near 太小、far/near 过大降低深度精度。先调整几何位置与裁剪范围,再评估特殊深度方案 |
| 高分屏超卡 | DPR=2 相比同一 CSS 尺寸的 DPR=1 需要 4 倍像素;同时检查阴影与后处理成本 |
| 改了数据画面不变 | 顶点数组变更需 attribute.needsUpdate;改变着色器配置(如从无贴图到有贴图)需 material.needsUpdate。普通 color / roughness 修改通常不需要重新编译,但都需要重画 |
| 换页后越来越卡 | 没有 dispose(),显存泄漏 |
| 动画速度不对 | 没乘 delta,帧率不同速度就不同 |
| 老教程代码报错 | 版本差异:先查 Migration Guide,再核对 THREE.REVISION |
4.4 核验依据与版本边界
2026-10-28 复核。本文以本站 r186 源码为行为基准,在线文档可能随新版更新。13 张 SVG 为教学示意;2.3 的分步画面来自实际 Demo(步骤 3 复用相同空画布画面),动画请运行 4 个 Demo 观察。下列链接可追查关键结论。
- 时间与隐藏页面:Timer 文档,必须 connect(document) 才启用可见性处理。
- UV 通道与颜色标记:Texture 文档;r186 的 WebGLPrograms 使用 aoMap.channel 选择 UV,Texture 源码默认 channel=0。
- 几何与绘制分组:BufferGeometry 文档,一个 group 会对应单独的 draw call。
- PBR 参数与照明:MeshStandardMaterial 文档。其中 AO 的第二套 UV 文字与 r186 通道机制有差异,本文按目标版本源码说明。
- 后处理输出:r186 OutputPass 源码,用于输出色调映射和颜色转换。
- 后端回退:WebGPURenderer 文档;Clock、阴影和 RenderPipeline 的版本范围见 迁移指南。