
总有人跑来问我同一个问题three.js 到底怎么入门。我一般会反问一句——你手头遇到的问题是什么这个问题不是客套而是我自己踩了三年坑之后最深的体会。网上关于 three.js 的教程铺天盖地但大多是按官方文档的章节顺序“从前往后讲”先介绍坐标系再介绍几何体一个 API 接一个 API看得人昏昏欲睡合上页面之后什么都记不住。今天这篇的标题就叫“three.js 从问题开始基础”。我不打算写一份大而全的文档导读而是从一个真实可感的需求倒推知识地图如果你想在当前这个网页里渲染出一个能看、能转、能拖拽的 3D 物体你到底需要知道哪些基础这篇内容适合完全没碰过 three.js 的新手也适合那些看过文档但概念还是糊成一团的人。我会按照问题展开每一个基础概念都用解决某个具体问题的方式讲出来最后还会附上高频问题排查清单。1. 为什么我建议从问题出发学 three.js1.1 先有需求再认识概念先看一个非常典型的入门场景。某天你接了一个需求给公司的产品展示页加一个可以旋转的 3D 模型。你的第一反应大概率是去搜“three.js 教程”然后打开一篇“three.js 入门指南”从创建场景开始一步步敲到几何体、材质、灯光、动画……问题就在这儿。教程会告诉你“Scene 是所有物体的容器”“PerspectiveCamera 是透视相机”但这些概念在没有上下文的情况下就是一堆名词。你真正的问题不是“Scene 是什么”而是“我要怎么把模型放到网页上”。从问题出发的意思是先把自己的需求拆成几个小问题然后再让每个基础概念在解决某一个小问题的过程中自然出场。等你把所有小问题解决完回头一看基础概念你已经全用了一遍。1.2 问题驱动带来的三个好处这种学习方式比顺序刷文档有优势核心有三个。第一个好处是目标感非常强。你每走一步都清楚自己的目标现在是让屏幕有颜色现在是放一个方块现在是让方块转起来。每完成一步你都能实实在在地看到结果这种反馈是保持学习动力最有效的东西。第二个好处是知识边界清楚。不需要一上来搞懂所有材质类型不需要理解全局光照不需要研究阴影贴图的原理。你只需要知道当前需求涉及哪几个类剩下的部分可以留给未来的问题。说白了three.js 的内容非常庞杂一次想全吞进去只会消化不良。第三个好处是概念的“锚点”更牢。当你经历过“加了材质但物体是黑的”这个 bug 之后你就再也忘不了光照的重要性。因为你是为了解决某个具体问题才认识这些概念的它们不是孤立的单词而是和你的实际经验绑在了一起。1.3 讲讲我走过的弯路我最早学 three.js 的时候犯过一个很蠢的错误。当时我找了一篇特别全的中文文档建了个文件夹准备从第一章“Three.js 核心概念”开始逐步往后学计划一个周末搞定。结果呢我看完了坐标系和场景章节又在几何体章节停下来然后因为搞不清材质和网格的区别又去搜资料最后整整两天时间都停留在前几章。最后让我真正入门的不是文档而是一个特别简单的问题想在一个网页上放一个会旋转的彩色方块。从那个小小的目标开始我只花了两个小时就把场景、相机、渲染器、几何体、材质、动画全串起来了。说真的那个彩色的方块可能是我见过最丑的 3D 图形但它让我第一次理解了 three.js 的整体工作流。从那以后我学任何图形学相关的新东西都会先给自己设定一个非常具体、非常小的问题。2. 渲染一个3D场景最小骨架到底需要几样东西2.1 场景、相机、渲染器三位一体的基本盘打开 three.js 的任何一个示例你几乎都会看到这三样东西Scene、Camera、Renderer。它们解决的是你最大的那个问题——怎么把一个三维的世界显示在二维屏幕上。说人话版本就是Scene 是舞台所有要展示的物体都摆在这个舞台里Camera 是镜头决定了你从哪个位置、朝哪个方向去看舞台Renderer 是放映机负责把镜头里看到的内容真正画到页面上。这三个角色缺一不可缺了任何一个都没有画面。官方最经典的写法是const scene new THREE.Scene(); const camera new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000); camera.position.set(3, 3, 5); camera.lookAt(0, 0, 0); const renderer new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); document.body.appendChild(renderer.domElement);注意 camera 创建出来之后它的默认位置在原点也就是和物体在一起拍不到什么东西。我见过很多新手把相机放在原地结果屏幕上什么都没有然后疯狂排查是不是物体没创建成功。这就是典型的“概念不清楚”导致的瞎猜。位置改了之后最好再调用一次 lookAt 让镜头对准目标点。2.2 相机核心参数与坐标系透视相机 PerspectiveCamera 的四个参数经常被照着抄很少有人理解它们为什么要这么设。我就用大白话给你拆一遍。fovfield of view是视场角单位是度值越大镜头“看到”的范围越大物体显得越小。45 是一个比较接近人眼感受的值所以绝大多数示例都写 45。aspect 是画布宽高比一般直接用 window.innerWidth / window.innerHeight如果这个值不对物体的形状就会明显被压扁或拉长。near 和 far 是近裁剪面和远裁剪面。这句话听起来很吓人其实意思很简单相机只能渲染位于这两个平面之间的物体比 near 更近或者比 far 更远的直接不画。有个常见的坑是 far 设得太大比如 100000这会导致深度缓冲精度不够物体表面出现闪烁、穿插的现象专业点叫 z-fighting。如果只是做小场景far 设 1000 完全够用。坐标系这块也得单独说一句。three.js 默认使用右手坐标系X 轴向右Y 轴向上Z 轴指向屏幕外。很多刚接触图形学的朋友习惯拿二维坐标系的直觉去理解就会在调整位置时搞反方向。每次遇到“物体跑到哪去了”的问题先别急着改代码花十秒钟在脑子里过一遍坐标系往往能救回半天时间。2.3 从空场景写起第一段可运行代码下面给出一份可以直接运行的最小骨架。为了保证代码结构清晰我用模块方式引入 three.jsimportmap 里的路径请根据你项目里的实际依赖地址替换!DOCTYPE html html head meta charsetutf-8 title最小 three.js 场景/title style html, body { margin: 0; height: 100%; overflow: hidden; } #app { width: 100%; height: 100%; } /style /head body div idapp/div script typeimportmap { imports: { three: ./three.module.js, three/addons/: ./examples/jsm/ } } /script script typemodule import * as THREE from three; const scene new THREE.Scene(); scene.background new THREE.Color(0x1a1a2e); const camera new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000); camera.position.set(3, 3, 5); camera.lookAt(0, 0, 0); const renderer new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); document.querySelector(#app).appendChild(renderer.domElement); function render() { requestAnimationFrame(render); renderer.render(scene, camera); } render(); /script /body /html这段代码运行之后你只会看到一片深蓝色背景但没关系这一步的成功非常重要。它意味着场景、相机、渲染器三个角色已经就位画面输出的通路已经打通了。接下来你往 scene 里加任何东西都可以立刻在屏幕上看到结果这种“看到即反馈”的节奏才是学图形学最舒服的节奏。2.4 为什么窗口缩放时要处理 resize第一段代码跑起来之后第二个问题很快就会出现你把浏览器窗口拉大或缩小画布内容却没有跟着变甚至会出现变形。原因很简单WebGLRenderer 的绘制尺寸是我们通过 setSize 设置好的而窗口尺寸变化后画布尺寸没有更新相机的 aspect 也还是旧值。解决方式比较标准就是监听 resize 事件重新设置渲染器尺寸同时更新相机 aspectwindow.addEventListener(resize, () { camera.aspect window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); });这里有个细节要强调updateProjectionMatrix 必须调用。相机内部有一个投影矩阵它是由 fov、aspect、near、far 算出来的你只改了 aspect 属性但不更新矩阵那相机还是旧视角屏幕上自然看不到任何变化。这件事我一开始经常忘后来养成一个习惯凡是动了相机参数下一步一定是更新投影矩阵没有例外。3. 物体立不起来几何体、材质与光照要配合3.1 一个纯色方块居然没有立体感最小场景搭好之后下一步自然是往里面放一个物体。最常见的选择是立方体因为它的结构最简单。但你可能会遇到一个很困惑的现象加了一个方块之后它看上去像是一个扁扁的、没有体积感的色块缺乏明暗变化一点“3D”的感觉都没有。我见过不少新手这时候会去怀疑显卡或者浏览器设置其实问题出在使用了一个不适合的材质。three.js 的 MeshBasicMaterial 是不受光照影响的它直接把颜色平铺到物体表面就像贴了一张彩色纸片。如果你需要体积感那就得换一种会受到光照影响的材质同时给场景里添加灯光。简单记需要立体感就要做两件事一是用带光照计算的材质二是往场景里加光。3.2 几何体、材质、网格三者的分工在 three.js 里创建物体通常不是一步完成的。一个完整的 3D 物体由三个对象组合而成Geometry 负责提供顶点、法线、面等几何数据Material 负责描述表面长什么样、如何响应光照Mesh 把二者组合起来作为真正可以被放入场景并参与渲染的对象。你可以这样理解Geometry 是积木的骨架形状Material 是积木外壳的油漆颜色和质感而 Mesh 是把骨架和外壳拼装好、放在世界坐标里的那个成品。三者分工以后灵活度就上来了。同一个立方体几何体可以换不同的材质做出金属感、粗糙感同一个材质也可以套在不同的几何体上。创建一个立方体的官方写法是这样的const geometry new THREE.BoxGeometry(1, 1, 1); const material new THREE.MeshLambertMaterial({ color: 0x3399ff }); const cube new THREE.Mesh(geometry, material); scene.add(cube);在执行完这段代码之后如果你没有加任何灯光屏幕上的立方体会是纯黑的或者非常暗。这是因为 Lambert 材质的颜色计算依赖于入射光环境里一点光都没有它就没有办法反射任何颜色的光给人眼。3.3 材质怎么选Lambert、Phong 还是 Standard材质的选择是新手最容易懵的地方。你不一定要把所有材质类型都背下来但最常用的三种得能区分出差别材质类型是否受光照影响特点与适用场景MeshBasicMaterial不受光照纯色平铺无体积感适合调试轮廓、UI效果MeshLambertMaterial受光照只有漫反射计算量低适合粗糙表面MeshPhongMaterial受光照有漫反射和镜面高光适合塑料、抛光表面MeshStandardMaterial受光照基于物理渲染效果更真实适合金属等材质实际项目里我很推荐一开始用 Lambert 材质原因只有一个它计算简单结果直观足够帮你理解光照配合的概念又不会因为参数太多而分心。等做的东西复杂了再换 Phong 或 Standard。所谓的高光specular highlight其实就是物体表面那个亮斑Phong 和 Standard 这类材质才有Lambert 算不出来。所以如果你发现球体的高光不见了先看看你用的材质是不是只支持漫反射。3.4 光照模型最简理解物体颜色是“光与材质商量出来的”很多人在图形学基础不够的情况下会默认“物体是什么颜色就显示什么颜色”。到了 three.js 里这个直觉就不成立了。屏幕上最终呈现的颜色是光源颜色、物体材质颜色、物体表面法线方向和观察角度共同作用的结果。打个比方。你穿了一件红色衣服站在日光灯下看起来是红色站在绿色灯光的房间里看起来就偏暗偏绿站到纯黑的环境里干脆什么都看不见。three.js 的实时渲染也是这个逻辑。物体材质里的 color 决定了它对不同波长光的反射倾向而最终能反射多少取决于光源的强度和角度。如果物体表面没有受到光线照射材质自身的颜色就完全显不出来。给场景加灯光的写法很简单const ambientLight new THREE.AmbientLight(0xffffff, 0.4); scene.add(ambientLight); const dirLight new THREE.DirectionalLight(0xffffff, 1); dirLight.position.set(3, 5, 4); scene.add(dirLight);AmbientLight 是环境光它没有方向从四面八方均匀地照亮所有物体主要用来防止阴影面彻底变成死黑DirectionalLight 是平行光用来模拟太阳那种从一个方向射来的光线它会为物体投射出明暗面产生体积感。通常的做法是“一主光一环境光”先保证能看清整体轮廓再用主光照出立体感。注意DirectionalLight 的位置会影响光照方向但它的光源位置并不参与距离衰减计算也就是说不会出现离得远就变暗的现象。想模拟距离衰减要换用 PointLight。4. 让页面动起来动画循环与鼠标交互的极简方案4.1 为什么动画要用 requestAnimationFrame物体显示出来了下一步当然是让它动起来。很多第一次接触图形学的朋友会下意识用 setInterval 去循环改变物体的旋转角度。其实在 three.js 的随便哪个官方示例里你都会看到 requestAnimationFrame而不是 setInterval。原因在于浏览器的工作方式。显示器的刷新率通常是 60Hz也就是每秒最多显示 60 帧画面。requestAnimationFrame 会与浏览器的绘制机制自动对齐只在这两帧之间的合适时机执行回调既不至于产生不必要的计算浪费也能让动画平滑流畅。setInterval 由于定时精度和绘制时机不稳定很容易出现掉帧、卡顿甚至你明明设置了一个频率却有几帧渲染了多余的内容。所以动画循环的标准写法是把 requestAnimationFrame 放在渲染函数内部让它在每一帧绘制完成后再次请求下一帧function animate() { requestAnimationFrame(animate); cube.rotation.y 0.01; renderer.render(scene, camera); } animate();这段代码看起来很简单但里面藏着一个新手特别容易忽略的性能习惯每一帧修改的是“最终会被渲染的属性”而不是“用来计算中间状态的临时变量”。如果你的场景里有大量物体每一帧去遍历它们并逐个修改属性性能就会迅速变差。动画循环里必须保持“最小更新”原则只改变当前这一帧真正要变化的东西。4.2 用时钟变量解决“帧率不同导致速度不同”上面那个写法有一个隐患让 cube 每帧旋转 0.01 弧度看似是匀速运动实际上它的速度取决于当前设备的帧率。如果显示器是 120Hz每秒钟会转 1.2 弧度如果显示器是 30Hz每秒钟只转 0.3 弧度。同一个页面在不同设备上转得不一样快这显然不合理。更规范的做法是使用时钟工具把旋转量乘以自上一帧以来经过的时间差deltaconst clock new THREE.Clock(); function animate() { requestAnimationFrame(animate); const delta clock.getDelta(); cube.rotation.y delta * 0.6; // 每秒旋转 0.6 弧度 renderer.render(scene, camera); } animate();delta 的单位是秒0.6 表示每秒旋转 0.6 弧度约等于每秒转 34 度这样在 30Hz 和 120Hz 的设备上一秒之内转过的角度都一致。这个“速度与帧率解耦”的思路在做任何连续动画时都要记得。游戏里的角色移动、镜头的平滑跟随本质都是同一套逻辑。4.3 鼠标拖拽观察与轨道控制器动画加上之后很快你就会有第二个需求能不能用鼠标拖一拖从不同角度观察这个物体如果不借助任何工具你需要自己监听鼠标事件计算旋转角度再把角度换算到相机位置上工作量不小。three.js 官方在 examples/jsm/controls 里提供了一个现成的类 OrbitControls翻译过来就是轨道控制器。它做的事情很直接让相机围绕着目标点旋转同时支持拖拽、缩放。使用时先通过 importmap 映射添加依赖路径然后在你的模块脚本里引入import { OrbitControls } from three/addons/controls/OrbitControls.js; const controls new OrbitControls(camera, renderer.domElement); controls.enableDamping true;如果使用 damping 阻尼效果必须在动画循环里调用 controls.update()否则阻尼会不生效。完整的动画循环就变成function animate() { requestAnimationFrame(animate); const delta clock.getDelta(); cube.rotation.y delta * 0.6; controls.update(); renderer.render(scene, camera); } animate();这里有个细节要提醒OrbitControls 默认会监听你传入的 DOM 元素的事件。传给它的 renderer.domElement 是渲染器创建的 canvas这样拖拽画布时才会生效。如果你传了一个包裹用的 div而且它上面有其他元素遮挡鼠标事件就容易被拦截掉。4.4 循环里的三个性能好习惯基础项目规模小性能问题不明显但动画循环里的一些习惯越早养成越好。第一个习惯是避免在循环体内部创建新的对象。你想想每帧执行一次如果里面有 new THREE.Vector3() 或者 new THREE.Color()一秒钟就要创建几十上百个临时对象垃圾回收压力直线上升。静态的临时对象应该在循环外面创建好循环里只修改它的属性。第二个习惯是不做无意义的渲染。如果场景里没有任何东西在变化就没有必要每秒重复 60 次渲染。three.js 的很多实际项目里面物体的动画是间歇性的、相机的移动是偶发的这时候完全可以在事件回调里手动调用一次 renderer.render而不是跑一个常驻的动画循环。第三个习惯是控制像素比。渲染器默认按 1 倍像素比绘制在高分屏上画面会显得模糊。大家都会设置renderer.setPixelRatio(window.devicePixelRatio);但注意如果你的设备像素比是 3很多手机都这样渲染器的实际绘制像素数量会变成原来的 9 倍性能开销非常夸张。比较稳妥的写法是限制上限renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));这样既保住了清晰度又不会让低端机直接卡死。5. 基础阶段高频问题与排查记录5.1 高频问题排查表写到这里我把身边新手朋友问得最多的问题整理成一张表。这些坑我都亲自踩过每一条都有很具体的代码原因绝对不是什么玄学。现象常见原因解决思路场景空白什么都没有相机没有对准物体 / near-far 范围不对 / 物体在相机后方在场景里加 AxesHelper 确定坐标检查 camera.position 和 lookAt物体纯黑或非常暗使用了受光照材质但没有添加任何灯光添加一个环境光 至少一个指向性光/点光源物体是扁平的色块用了 MeshBasicMaterial不受光照影响换成 Lambert/Phong/Standard 材质并加灯光窗口变化后画面变形没有监听 resize相机 aspect 没有更新在 resize 事件里更新 aspect 并 updateProjectionMatrix物体不绕自己中心旋转几何体的中心点不在原点对 geometry 调用 center()或手动调整 Mesh 位置画布模糊没有设置 setPixelRatio或上限设得太高设置 Math.min(devicePixelRatio, 2)画面卡顿每帧创建大量对象 / 像素比过高 / 阴影计算过重循环外复用对象检查像素比按需开启阴影挑其中两个说说我自己的血泪史。物体不绕自己中心旋转这个坑经常出现在你加载外部模型的时候。比如你从一个资源站下载了一个模型文件直接 load 进场景转动它却发现它绕着某个不知道哪里来的点转就是因为这个模型本身的几何中心不在原点。解决方法很粗暴对 geometry 调用 center() 即可它会自动平移几何体的顶点数据使包围盒的中心回到原点。画面模糊的问题也很典型。我在一个高分屏笔记本上调试发现页面里模型边缘明显有锯齿和模糊感一开始以为渲染设置不够“高级”调了一堆参数都没用。最后发现只是忘了 setPixelRatio浏览器默认按 1 倍像素比绘制高分屏看起来自然就是糊的。这个问题其实一行代码就能解决。5.2 调试时可以显示的辅助工具新手阶段建议把调试工具也并行用起来。三个最值得使用的辅助类是 AxesHelper、GridHelper 和 OrbitControls 本身。AxesHelper 会在场景里画三条带颜色的轴直观标出 X、Y、Z 方向。把它加到场景里后你能立刻判断出物体到底在什么位置相机的朝向是不是正确。GridHelper 则会在 XZ 平面上画一个网格帮你快速建立“地面”的参考系判断物体的高度。const axesHelper new THREE.AxesHelper(5); scene.add(axesHelper); const gridHelper new THREE.GridHelper(10, 10); scene.add(gridHelper);很多人忽略的一点是调试工具不是项目的一部分但它能帮你把抽象的问题变成直观的视觉反馈。遇到物体方位不对、旋转轴不对这类问题先把辅助工具打开十有八九一眼就能看出原因。等调完了再把它从场景里移除不会影响最终效果。5.3 三个继续深挖的建议基础概念全部串起来之后你可能会觉得“好像懂了又好像不够用”。这是很正常的状态因为 three.js 的内容量比基础框架大得多。我给你三个方向作为下一步的学习建议。第一个方向是“交互层”。你现在已经会用 OrbitControls 观察物体了但还没有做任何点击、拖拽物体本身的操作。three.js 的射线检测Raycaster就是解决这个问题的工具。它可以帮你实现“鼠标点到了哪个物体”“物体被鼠标拖走了”之类的交互效果。第二个方向是“模型资产层”。实际项目中你不会总是用 BoxGeometry 这种基础几何体做主角更多时候你需要加载外部模型文件。理解 GLTF 格式、模型的加载与异步处理、动画资源的播放这些内容都值得顺着一个问题深挖下去。第三个方向是“渲染质量层”。当你希望画面更炫的时候会发现光照、阴影、材质贴图、环境贴图、后期处理都是连在一起的。这些内容听起来很多但它们都遵循同一个原则你带着一个具体的问题去学比如“怎么让金属材质有真实的反光”那所有的相关概念都会围绕这个目标形成体系。最后再分享一点个人体会学了这么久 three.js我自己最深的体会其实是基础不是背出来的是修 bug 修出来的。每一次“怎么又黑了”“怎么又不见了”“怎么转得不对”都是你和图形学底层逻辑的一次握手。就算你现在把所有基础 API 全部背下来遇到新问题依然会慌反过来你带着问题一步步查、一步步试那些概念会自然长在脑子里。所以如果你正打算学 three.js我强烈建议你今天别再看那些大而全的文档速览了。先给自己定一个特别小、特别具体的目标哪怕只是一个会旋转的彩色方块然后从“这个方块为什么不转”开始一步一步往下走。三年前的我觉得 three.js 门槛很高现在回头看它真正难的地方从来不是代码语法而是你能不能把眼前的现象和背后的原理联系起来。这个问题想通之后基础其实就这么点东西剩下的都是你在解决下一个问题时顺手学会的。