知识分享 · 01 / THREE.JS & WEBGL

Three.js & WebGL 系统学习教程

从顶点数据到屏幕像素,沿着一条可观察的绘制过程学习。先理解 WebGL,再逐步搭建 three.js 场景;每段代码都说明准备了什么、何时出现画面、应该看到什么。

版本 three@0.186.1 4 个可运行 Demo 13 张重绘原理图 6 幅分步画面 Hello Cube · 逐步观察 核验于 2026-10-28

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 倍像素。
图 1 · WebGL 与 three.js 的层次关系:three.js 是「翻译层」,不是替代品
图 1 · WebGL 与 three.js 的层次关系:three.js 是「翻译层」,不是替代品
对比项WebGLthree.js
本质基于 OpenGL ES、为浏览器设计的图形 API;WebGL 2 对应 ES 3.0 的能力体系3D 库;本文用 WebGLRenderer,也有面向 WebGPU 的渲染器
能画什么只有点、线、三角形模型、灯光、阴影、材质、骨骼动画…
准备工作手动编译着色器、上传缓冲、设置属性与状态用几何体、材质和对象表达场景,由渲染器管理底层步骤
学习价值理解 GPU 如何工作、性能瓶颈在哪快速做出能看的东西
什么时候必须懂底层自定义着色器、粒子/GPU 计算、诡异的渲染 bug、极致性能优化
建议的学习顺序第 1 章(WebGL 原理,只求看懂不求手写)→ 第 2 章(three.js 主线,边写边跑)→ 第 3 章(需要时再深入着色器与性能)。不要在第 1 章死磕手写 WebGL。

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 从顶点到片元的渲染管线

图 2 · WebGL 图形渲染管线。three.js 的每个材质、每个渲染设置,最终都落在这条流水线上
图 2 · WebGL 图形渲染管线。three.js 的每个材质、每个渲染设置,最终都落在这条流水线上

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。交错布局有利于部分访问模式,但是否更快需要测量。

先准备三个顶点的数据,再装配三角形,最后光栅化并插值颜色;前两幅是内部状态示意
过程图 · 上传数据不会先在屏幕画三个点;线框也是装配过程示意,程序直接绘制填色三角形。
  1. GLSL 源码 → programvsSrc 与 fsSrc 只是字符串;编译、链接、useProgram 后 GPU 才知道使用哪套计算规则,此时仍没图形。
  2. 数组 → 缓冲三个顶点 A=(0,0.6)、B=(−0.6,−0.4)、C=(0.6,−0.4)。w=1 时裁剪坐标除以 w 不变;上传与属性指针只准备输入。
  3. drawArrays → 三角形读取 3 个顶点,按 uAngle 旋转;gl.TRIANGLES 将它们组成一个三角形。首次角度为 0 时,A 在上、B 在左下、C 在右下。
  4. 插值 → 填色区域三角形内部的 RGB 随顶点颜色插值,片元着色器写出颜色;Demo 每帧改变角度并重画,使用正方形 viewport 保持比例。
Demo 1 · 原生 WebGL2 三角形(随时间自动旋转)全屏打开 ↗
代码边界上面是核心片段,省略了上下文获取、编译链接、useProgram()、属性位置查询、enableVertexAttribArray()、uniform 和视口设置,完整流程见 Demo 1 源码。Three.js 简化了这些工作,但仍需要初始化场景、相机和渲染器,不能只用三行独立运行。

1.4 坐标系与三大矩阵

3D 最劝退的部分其实是矩阵。但你要理解的只有一条链条:顶点从「模型自己的坐标」一路被变换到「屏幕像素」。

图 3 · 顶点坐标变换链。理解这条链,90% 的「物体怎么不见了」「为什么这么大」类问题都能自己排查
图 3 · 顶点坐标变换链。理解这条链,90% 的「物体怎么不见了」「为什么这么大」类问题都能自己排查
空间关键矩阵你在 three.js 里怎么改它
局部 → 世界modelMatrixmesh.position.set() / rotation / scale
世界 → 相机viewMatrixcamera.position / camera.lookAt() / OrbitControls
相机 → 裁剪projectionMatrix透视相机:fov aspect near far;正交相机:left/right/top/bottom
坐标系约定与变换顺序Three.js 默认世界空间为右手系、Y 向上,相机沿自身 −Z 方向观察;WebGL 本身不规定你的模型采用什么世界坐标约定。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 把裸模块名映射到具体文件,浏览器原生支持,不需要打包工具。学习阶段最省事。

先搭好 HTML 入口方案 A 还需要 index.html,在 body 中放入 <script type="module" src="/main.js"></script>。方案 B 的 importmap 必须位于使用它的 module 脚本之前。两者都通过 HTTP 服务访问,避免直接双击 file://;CDN 方案首次访问需要网络。本站 Demo 使用本地 /vendor/three-0.186.1/,无需外部 CDN;首次打开页面仍需能访问本站或本地服务。
版本核对:区分 WebGL 与 WebGPU
PCFSoftShadowMapWebGLRenderer 在 r182 弃用它;r186 的移除说明针对 WebGPURenderer。本文统一用 PCFShadowMap,不要把两个后端的迁移说明混用。
Clock已废弃,改用核心类 Timer(timer.update() → getDelta())
PostProcessingWebGPU 侧已改名 RenderPipeline(WebGL 的 EffectComposer 不变)
模块导入本教程使用 ES Module;浏览器通过 importmap 或构建工具解析 three。不要混用旧版全局 THREE 脚本和不同版本的 addons。
WebGPU可用性取决于浏览器、系统、驱动和安全上下文。运行时检测并处理初始化失败,不能只根据浏览器名称判断。

2.2 三大件 + 场景图

任何 three.js 程序都由三样东西撑起来:Scene(装东西的容器)、Camera(从哪看)、Renderer(画出来)。而 Scene 里的对象是一棵树——这棵树叫场景图(Scenegraph)。

图 4 · 场景图是一棵树。父节点变换会传递给所有子节点(three.js 官方文档 Fundamentals 一节的经典示意)
图 4 · 本站重绘:树枝表示真实父子关系,相机与 Renderer 的作用单独说明。
  1. 创建 Scene建立根容器,屏幕不会因此出现物体。
  2. 添加 Mesh / Group每个对象只有一个父节点;Group 用来整体移动、旋转一组对象。
  3. 设置 Camera确定观察位置和投影,不必加入 Scene;若相机挂着灯等子对象,则要让这些子对象进入被遍历的树。
  4. 调用 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);
此时看到什么?仍然是网页背景,没有 canvas。Scene 是对象容器;相机在原点外,从斜上方看原点。lookAt() 只改变观察方向,不画相机模型。60 是垂直视场角,0.1 和 100 是裁剪距离。
步骤1:白色网页,尚未创建3D画布,只有教学提示
步骤 1 · Demo 的网页区域;提示文字用来说明没有画布的状态,Scene 和 Camera 不是可见物体。

步骤 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() 清出背景。场景还没有可画的几何体。
步骤2:实际渲染的深蓝空画布,没有任何几何体
步骤 2 · 首次 render 后只有深蓝背景;左上角是 Demo 的状态提示。

步骤 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);
此时看到什么?画布依旧不变。盒子边长为 1,局部坐标每轴范围为 −0.5~0.5;Geometry 准备顶点和索引,Material 准备着色参数,Mesh 组合两者。没有 scene.add(cube),下一次渲染也不会遍历到它。
步骤3:与步骤2相同的空画布;创建几何体和材质不会立即绘制
步骤 3 · 画面与步骤 2 相同,因此复用同一空画布截图。形状存在于数据中,尚未参与绘制。

步骤 4 / 加入场景,观察没有照明的物体

scene.add(cube);
renderer.render(scene, camera);
此时看到什么?深蓝背景上的黑色立方体轮廓。默认 Standard 材质的自发光为黑,场景又没有灯和环境照明,所以它没有可见的受光颜色。scene.add() 本身不会刷新画面;第二行才提交绘制。黑色轮廓也与背景对比有关。
步骤4:深蓝背景中心出现黑色立方体轮廓,说明几何体已经被绘制但没有照明
步骤 4 · 从实际 WebGL 画布导出:已画出盒子,但无光照时表面为黑色。

步骤 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);
此时看到什么?紫蓝色立方体,不同朝向的面出现明暗差。半球光按法线方向提供天空色与地面色;平行光朝默认 target(原点)照射。灯不会自动画出灯泡;这里没有地面,也没有启用 shadowMap,因此没有地面投影。
步骤5:相同位置的紫蓝色立方体,顶面较亮、两个侧面明暗不同
步骤 5 · 相机和几何体未动,只增加照明;现在能辨认三个面的明暗。

步骤 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);
});
此时看到什么?立方体绕自己的 Y 轴连续旋转;拖拽改变相机视角。每秒转 0.6 弧度,一圈约 10.47 秒。delta 的单位是秒,60 FPS 时每帧约增加 0.01 弧度。resize 只改投影与画布大小,不改变盒子的世界尺寸。
步骤6:动画循环运行后,立方体转到另一个角度,轮廓和面明暗随之变化
步骤 6 · 动画运行中的一帧。静态图片只记录一个时刻,连续变化见下方 Demo。
每一帧依次更新时间、修改角度、重新渲染,画布才显示新角度
过程图 · 修改角度是改数据;render 才把新的角度变成画面。
Demo 2 · Hello Cube:切换 6 个阶段,对照真实画面全屏打开 ↗
三条铁律 ① 以「每秒增量」驱动的动画要乘 delta;也可以直接用 elapsed 计算绝对角度。固定每帧增量会受帧率影响; ② 窗口 resize 时必做三件事:改 camera.aspect → updateProjectionMatrix() → renderer.setSize(); ③ 用 renderer.setAnimationLoop() 而不是手写 rAF,WebXR 环境下它能自动接管。

2.4 几何体 Geometry:顶点是怎么组织的

常用几何体都建立在 BufferGeometry 上:用类型化数组保存顶点属性,并可选用 index 复用顶点。position 常用浮点数,索引通常使用无符号整数;不是所有缓冲都是浮点数组。

图 5 · BufferGeometry 的结构。index 索引复用能把立方体顶点从 36 个降到 24 个
图 5 · 编号复用的是整组顶点属性。默认 BoxGeometry 有 24 组属性、36 个索引,组成 12 个三角形。

用 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);
每一步的画面setAttribute 只准备四组位置 → setIndex 描述两个三角形 → new Mesh / scene.add 组合并挂入树 → render 后出现实心正方形(斜视时是四边形),不会自动画出中间对角线。单独观察时先 scene.remove(cube),避免被原盒子遮住。Basic 材质不需要法线;改成受光材质前需提供或计算法线。修改顶点位置后,还应重算依赖它的包围体。
九种几何体直观对照:盒子;分段少与分段多的球;从 XY 面旋转为地面的平面;圆柱、圆锥、圆环;沿曲线生成的管道;从 L 形轮廓挤出的立体;由三个顶点构成的自定义三角形
几何体外观速查 · AI 生成的概念示意图,线条用于辅助理解形状,不代表精确三角网格。点击可查看大图。
先看形状,再看参数上图中的类名决定“生成哪种形状”;尺寸、分段数等参数再决定“多大、多细”。重点对比球体的分段少 / 多、平面的竖直 / 水平,以及曲线 / 管道、轮廓 / 挤出体这三组生成关系。
常用内置几何体说明
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 的两个核心参数

图 6 · 四颗实际渲染的球体:左组固定金属度为 0,对比粗糙度 0 的清晰反射与粗糙度 1 的柔和反射;右组固定粗糙度为 0.25,对比金属度 0 的非金属与金属度 1 的有色镜面反射
图 6 · 两组 0 → 1 实际渲染对照,每组只改变一个参数。观察高光、反射轮廓与颜色;更多中间值见 Demo 3。
光源特性
AmbientLight无方向的环境照明项,可以单独使用,但缺少方向性的明暗层次;不投射阴影
HemisphereLight天空色 + 地面色,模拟环境光。打底首选
DirectionalLight平行光(太阳)。支持阴影;PointLight 与 SpotLight 也支持,注意范围与成本
PointLight点光源(灯泡)。距离衰减,阴影开销大( cube shadow )
SpotLight聚光灯,带角度与边缘虚化
环境贴图 envMap提供来自周围场景的照明与反射。可用 PMREMGenerator + RoomEnvironment 生成;没有环境贴图时,金属仍可反射直接光,但外观通常缺少环境细节
新手最常见的三个「黑屏」原因① 用了 PBR 材质但没加灯;② 相机在物体内部或被 near 面裁掉;③ 材质颜色设成黑色又用了 metalness=1 且没有环境贴图。
按顺序做材质实验在 2.3 中将 cube.material 换为 MeshBasicMaterial:不用灯也有纯色 → 换回 Standard:需要照明才有层次 → 保持灯和相机固定,只改 roughness:观察高光宽度 → 再只改 metalness:观察反射变化。Demo 3 上排固定 roughness=0.25、metalness 从 0 到 1;下排固定 metalness=0.1、roughness 从 0 到 1,两排都步进 0.2,并共用程序生成的环境反射。

2.6 纹理与 UV

纹理是供着色器采样的数据,可以保存颜色,也可以保存法线、粗糙度等信息。UV 是顶点上的二维采样坐标,通常用 0~1 对应一张图,但允许超出这个范围;越界结果由 wrapS / wrapT 决定。

图 7 · UV 映射与纹理关键设置。颜色空间设错是最常见的「画面发灰/发白」元凶
图 7 · UV 映射与纹理关键设置。颜色空间设错是最常见的「画面发灰/发白」元凶

下面用程序生成棋盘贴图,没有缺失的图片文件,也无需等待网络。前提:已有 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,需要更新着色器配置
  1. 画棋盘先在独立的 2D canvas 上生成两色图案,3D 画布还没变。
  2. 建立 Texture将图案作为纹理来源;颜色图标记 sRGB,法线、粗糙度、AO 等数据图通常保持 NoColorSpace。
  3. 设置平铺repeat=(4,4) 将完整图案重复 4×4 次;BoxGeometry 每个面的默认 UV 都覆盖整张纹理。
  4. 指定 map 并重画下一帧出现带棋盘格的立方体,几何轮廓不变,灯光仍影响表面明暗。
AO 与 UV:以目标版本源码为准r186 通过 texture.channel 选择属性:0 对应 uv,1 对应 uv1。AO 并不固定要求 uv1;只有 aoTex.channel = 1 时才需要几何体提供 uv1。旧版示例和部分 API 注释仍写「必须第二套 UV」,不能照搬。图片上下方向还受加载路径与 flipY 影响,不能把「图片左下角」当成统一约定。

2.7 相机:你能看见的那一块「视锥」

相机定义了「世界的哪一块会被画出来」。透视相机的可视范围是一个被截断的金字塔——视锥体(Frustum),视锥剔除(Frustum Culling)的名字就来自这里。

图 8 · 透视相机的视锥体:四个参数决定你能看见什么,也决定深度精度
图 8 · 透视相机的视锥体:四个参数决定你能看见什么,也决定深度精度
参数变化如何影响画面?物体和相机位置不变时,减小 fov 会缩小取景范围,让盒子在画面中变大;增大 fov 则相反。near / far 不负责缩放,而是裁掉范围外的几何部分。改完投影参数后调用 updateProjectionMatrix() 再重画。只改 fov 不改变透视关系;常说的「长焦压缩感」还涉及为保持构图而移动观察位置。
相机特点用途
PerspectiveCamera近大远小,符合人眼绝大多数场景
OrthographicCamera无透视,尺寸恒定等轴测、2.5D、CAD、UI 正交预览
ArrayCamera多相机拼接VR / 多屏
CubeCamera6 面渲染成环境贴图实时反射(镜子、车漆)

2.8 阴影:Shadow Map 的原理与调优

本文使用 Shadow Map 阴影:先从光源视角记录可见表面的深度,再将相机视角的待着色表面变换到相同光源坐标系中比较深度。被更近的表面遮挡时,削弱该灯的直接光照;环境光不会因此全部消失。

图 9 · Shadow Map 两步走。理解原理后,阴影相关的怪现象(条纹、偏移、断层)都能对症下药
图 9 · 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。

  1. 开阴影功能仅开 renderer 开关,画面通常还不会出现阴影。
  2. 灯和盒子都 castShadow允许从 sun 的视角生成盒子的深度。没有接收面,仍看不到地面投影。
  3. 添加水平地面Plane 默认在 XY 面;转 −π/2 后朝 +Y,放到 y=−0.5 与盒子底部相接。
  4. 地面 receiveShadow下一帧在背光一侧出现投影;扩大 shadow.camera 范围会覆盖更多地面,但降低细节精度。
▶ Demo 3 · 材质 / 光照 / 阴影(上排 metalness 渐变,下排 roughness 渐变)全屏打开

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()
过程与结果先停止持续循环 → 绘制一次静止立方体 → 拖拽时 change 触发重画 → 松手后不再持续绘制。若开启阻尼,需要安排后续帧并调用 controls.update();不能直接照搬这个无阻尼版本。
Timer 的隐藏页面处理需要连接 document先执行 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);
  }
});
  1. 点击位置 → NDC画布左上是 (−1,+1),中心是 (0,0),右下是 (+1,−1)。按画布矩形换算,嵌入文章也不会偏移。
  2. 相机 → 射线 → 交点从点击位置构建射线,hits 按距离排序,取最近命中的物体。
  3. 改色 → 再渲染命中盒子后变红;点空白则保持不变。复杂模型可能使用材质数组或共享材质,需另外处理。
性能提示拾取时先用一个不可见的包围盒/简化代理做粗筛,或者只传需要拾取的数组(不要每次 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();
先生成细分平面,再沿局部 Z 轴位移,最后按高度从蓝到粉上色
过程图 · 细分提供可移动的顶点;仅四个角的平面无法形成平滑波浪。
  1. 复制 positionp 是当前顶点的局部坐标,JS 中的原始顶点数组不会被这个着色器修改。
  2. 算高度 h两个振幅 0.25 的波叠加,h 的理论范围为 [−0.5,0.5];p.z += h 沿局部 Z 位移。整张平面旋转后,方向随之改变。
  3. 投影并插值矩阵把新位置投影到画面;vHeight 随三角形插值到片元。用 h+0.5 映射到 [0,1],避免 mix 外插。
  4. 推进时间每帧更新 uTime 并渲染才会动。Demo 额外叠加了 UV 网格。此例不计算物理光照,也不自动更新 CPU 包围体、射线拾取形状与阴影位移。
▶ Demo 4 · ShaderMaterial 顶点波动平面全屏打开
两条进阶路线 ① ShaderMaterial:完全自己写,灵活但要自己处理光照; ② WebGLRenderer 可用 onBeforeCompile 定制内置着色器;WebGPURenderer 应使用 TSL / NodeMaterial。两者不是可直接互换的 API;位移效果仍需同步处理法线与阴影。

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);
RenderPass 输出场景纹理,Bloom 处理高亮区域,OutputPass 转换到最终输出颜色
过程图 · 保留已有动画更新,只替换最终渲染入口;不要另建一个循环覆盖原来的动画。
逐步看结果只有 RenderPass:先得到原始场景纹理;加 Bloom:高亮区域向周围扩散;加 OutputPass:完成最终显示转换。Bloom 的 threshold=0.85 不保证所有物体都发光,可用高于 1 的 emissiveIntensity 做高亮测试。这个片段需要同版本 addons,不是额外的完整 Demo。

WebGPU 侧使用 RenderPipeline 和节点效果,不能直接复用 WebGL 的 EffectComposer passes。

3.3 性能优化:减少提交,也要控制像素成本

手段解决什么代价
InstancedMesh共享几何体与材质的重复物体,通常每材质组、每 pass 一次实例化绘制变换与颜色可逐实例设置;自定义属性需额外设计。阴影、多材质等会增加调用
BatchedMesh批量组织同材质、不同几何体的对象容量、排序、透明度与剔除需评估,不能承诺所有设备都只有一次底层调用
合并几何体 mergeGeometries静态大场景合并后无法单独控制子物体
纹理图集 / 共享材质减少材质切换UV 要重新排布
LOD / 视锥剔除远处用低模多套模型资源
setPixelRatio(min(dpr,2))高分屏最直接的提帧手段略糊
释放资源前确认所有权移出场景不等于释放 GPU 资源。下面只适用于 2.3 中独占几何体和材质的 cube;若其他对象仍共享它们,不要提前 dispose。
// 结束整个示例时:
renderer.setAnimationLoop(null);
controls.dispose();
timer.dispose();
scene.remove(cube);
cube.geometry.dispose();
cube.material.map?.dispose(); // 若执行了 2.6,纹理也是本例独占
cube.material.dispose();
renderer.dispose();
执行过程先停循环与交互 → 把对象移出树 → 释放独占资源 → 销毁渲染器。清理代码本身不会绘制新图形;它结束示例。材质数组、其他贴图、后处理目标、DOM 事件和 observer 也要按所有权分别清理。

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()。

  1. 发请求并解码等待期间场景不会自动出现模型,下载进度也不等于解码完成。
  2. 加入 gltf.scene下一次 render 才出现模型;若尺寸、相机范围或灯光不合适,加入成功也可能看不到。
  3. 播放动画play() 激活轨道,mixer.update(delta) 推进姿态,render 显示新的姿态。

3.5 WebGPU 与 TSL:现在该学吗

项说明
可用性需要检查浏览器、系统、驱动与安全上下文。WebGPURenderer 优先尝试 WebGPU,不可用时可使用 WebGL 2 后端;仍应处理初始化失败
怎么用import { WebGPURenderer } from 'three/webgpu';,初始化是异步的(await renderer.init())
TSLThree 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 观察。下列链接可追查关键结论。