☰
three.js入门基础:从问题出发学3D场景渲染
2026/10/13 5:10:01 网站建设 项目流程

总有人跑来问我同一个问题:three.js 到底怎么入门。我一般会反问一句——你手头遇到的问题是什么?这个问题不是客套,而是我自己踩了三年坑之后最深的体会。网上关于 three.js 的教程铺天盖地,但大多是按官方文档的章节顺序“从前往后讲”,先介绍坐标系,再介绍几何体,一个 API 接一个 API,看得人昏昏欲睡,合上页面之后什么都记不住。

今天这篇的标题就叫“three.js 从问题开始:基础”。我不打算写一份大而全的文档导读,而是从一个真实可感的需求倒推知识地图:如果你想在当前这个网页里渲染出一个能看、能转、能拖拽的 3D 物体,你到底需要知道哪些基础?这篇内容适合完全没碰过 three.js 的新手,也适合那些看过文档但概念还是糊成一团的人。我会按照问题展开,每一个基础概念都用解决某个具体问题的方式讲出来,最后还会附上高频问题排查清单。

1. 为什么我建议从问题出发学 three.js

1.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 的四个参数经常被照着抄,很少有人理解它们为什么要这么设。我就用大白话给你拆一遍。

fov(field 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.js,importmap 里的路径请根据你项目里的实际依赖地址替换:

<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>最小 three.js 场景</title> <style> html, body { margin: 0; height: 100%; overflow: hidden; } #app { width: 100%; height: 100%; } </style> </head> <body> <div id="app"></div> <script type="importmap"> { "imports": { "three": "./three.module.js", "three/addons/": "./examples/jsm/" } } </script> <script type="module"> 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 事件,重新设置渲染器尺寸,同时更新相机 aspect:

window.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 弧度。同一个页面在不同设备上,转得不一样快,这显然不合理。

更规范的做法是使用时钟工具,把旋转量乘以自上一帧以来经过的时间差(delta):

const 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 门槛很高,现在回头看,它真正难的地方从来不是代码语法,而是你能不能把眼前的现象和背后的原理联系起来。这个问题想通之后,基础其实就这么点东西,剩下的,都是你在解决下一个问题时顺手学会的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询