Three.js 零基础入门:四要素手搓一个会转的 3D 立方体

用场景、相机、渲染器、几何体加材质四要素手搓一个会转的3D立方体,零基础入门Three.js的世界。

Three.js 零基础入门:四要素手搓一个会转的 3D 立方体

你有没有盯着浏览器里那些酷炫的 3D 产品展示、可旋转的模型预览,甚至是小游戏,心里冒出一个念头:这玩意到底是怎么做出来的?如果回到几年前,答案会让人泄气——原生 WebGL 要你自己写顶点着色器、管理缓冲区、手算投影矩阵,光是画一个会转的方块,上百行代码打底,图形学基础不牢根本玩不转。

好消息是,Three.js 把这一切都替你封装好了。同样一个旋转立方体,用 Three.js 十几行就能跑起来;它本质上是对 WebGL 的一层友好封装,让你不必精通图形学也能上手,同时又在你需要时保留了足够的底层能力。这篇文章的目标非常明确:带你从零开始,用「场景、相机、渲染器、几何体加材质」这四个核心要素,搭出一个最小可运行的 3D 场景,并且让它真正转起来。读到最后,你不仅能跑通代码,更会建立起一套可以复用到任何 Three.js 项目的思维模型。

一、先认清:任何 Three.js 程序都逃不开这四样东西

很多人学 Three.js 卡在第一步,不是因为概念难,而是因为不知道「最小需要哪些东西」。其实剥开五花八门的封装,任何 Three.js 程序都逃不开四个要素:

  • 场景(Scene):装下所有物体、灯光、相机的那个「舞台」;
  • 相机(Camera):决定你从哪个位置、用什么视野去看;
  • 渲染器(Renderer):把 3D 数据计算成 2D 像素、画到屏幕上的「后期团队」;
  • 物体(Geometry 加 Material):由「形状」和「外观」组合而成的可见对象。

我习惯用「拍电影」来理解这四者,非常直观:场景就是摄影棚,所有演员、道具、灯光都摆在里面;相机就是那台摄影机,它的机位和镜头决定了观众最终看到的画面;渲染器是后期团队,负责把拍摄到的内容渲染成成片;而几何体加材质,就是你要拍的那个主角——几何体决定它长什么样,材质决定它穿什么衣服、什么质感。

记住这个框架,后面无论场景多复杂,你都不会迷路。下面我们逐个拆开讲,但先别急着一个一个来——为了让你最快看到效果,我先把四样东西合体成一个能直接运行的完整示例,你复制保存成 .html 双击打开,就能看到一个会转的立方体。

二、环境准备:把 Three.js 请进页面

在写代码前,得先把库请进来。最常见有两种方式。

第一种,通过 CDN 直接引入,适合快速体验和单文件 demo。你只需在 HTML 里加一行 <script src="https://unpkg.com/three@0.160.0/build/three.min.js"></script>,就能拿到全局的THREE对象,本篇文章的示例都采用这种方式,方便你复制粘贴立刻运行。

第二种,通过 npm 安装,适合工程化项目:执行 npm install three,然后在模块里用import * as THREE from 'three' 导入。两种方式本质一样,只是模块系统不同。新手建议先用 CDN 跑通手感,再迁移到工程化也不迟。

三、四要素合体:最小可运行的旋转立方体

下面这段代码,就是把前面说的四样东西一次性凑齐的最小示例。别被几十行吓到,每一行我都加了中文注释,你对照着看完全能懂。把它整段存成一个.html文件,用浏览器打开,就能看到一个在深色背景里匀速旋转的绿色立方体。

html
复制
<!DOCTYPE html><html lang="zh-CN"><head> <meta charset="UTF-8" /> <title>Three.js 第一个旋转立方体</title> <style> body { margin: 0; overflow: hidden; background: #0b0b1a; } canvas { display: block; } </style></head><body> <!-- 引入 Three.js(CDN 上的 UMD 构建,可直接用全局 THREE 对象) --> <script src="https://unpkg.com/three@0.160.0/build/three.min.js"></script> <script> // ===== 四大要素之一:场景 ===== // 场景是容纳所有物体、灯光、相机的"舞台" const scene = new THREE.Scene(); // ===== 四大要素之二:相机 ===== // 透视相机:参数依次为 视野角度 fov、宽高比 aspect、近裁剪面 near、远裁剪面 far const camera = new THREE.PerspectiveCamera( 75, // 视野角度,单位度,越大越广角 window.innerWidth / window.innerHeight, // 宽高比,跟随窗口,避免画面被拉伸 0.1, // 近裁剪面,比这更近的不渲染 1000 // 远裁剪面,比这更远的不渲染 ); camera.position.z = 5; // 把相机沿 z 轴往后挪,才能看到原点处的立方体 // ===== 四大要素之三:渲染器 ===== // 渲染器负责把 3D 场景"画"到网页的 canvas 上 const renderer = new THREE.WebGLRenderer({ antialias: true }); // 开启抗锯齿 renderer.setSize(window.innerWidth, window.innerHeight); // 设定画布尺寸 renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // 适配高清屏,上限 2 document.body.appendChild(renderer.domElement); // 把 canvas 挂到页面 // ===== 四大要素之四:几何体 + 材质 ===== // 几何体决定"形状",材质决定"外观" const geometry = new THREE.BoxGeometry(1, 1, 1); // 长宽高都是 1 的立方体 const material = new THREE.MeshBasicMaterial({ color: 0x00ff88 }); // 绿色基础材质 const cube = new THREE.Mesh(geometry, material); // 把形状和外观组合成网格 scene.add(cube); // 把立方体放进场景 // ===== 动画循环:让立方体转起来 ===== function animate() { requestAnimationFrame(animate); // 浏览器在下一次重绘前回调本函数,形成循环 cube.rotation.x += 0.01; // 绕 x 轴每帧旋转一点 cube.rotation.y += 0.01; // 绕 y 轴每帧旋转一点 renderer.render(scene, camera); // 用相机视角把场景渲染到画布 } animate(); // 启动动画 </script></body></html>

如果一切正常,你现在应该能在黑色背景里看到一个匀速旋转的绿色立方体。这短短几十行,已经包含了 Three.js 的全部骨架。后面我们要做的,无非是在它之上「加戏」——加光、加交互、加响应式。但在加戏之前,我们回到这四行核心,逐个把它讲透,确保你不是「能跑但不知道为什么能跑」。

四、要素一:场景(Scene)—— 装下一切的舞台

场景是所有内容的容器,它不负责「怎么显示」,只负责「有什么」。你创建的每一个网格、每一盏灯,原则上都可以用scene.add(xxx)加进来。

js
复制
// 创建一个空场景,它现在什么都没有,只是一个容器const scene = new THREE.Scene();// 可选:给场景设一个背景色,让画面不那么空(参数为十六进制颜色)scene.background = new THREE.Color(0x0b0b1a);// 后面所有可见对象都通过 add 进入场景:网格、灯光、辅助线都如此// scene.add(某个网格或灯光);

场景本身几乎没有可配置项,关键是养成「随手往里加东西」的习惯。后面所有 Object3D——网格、灯光、相机辅助线——都通过 scene.add进入这个世界。你甚至可以给场景设一个背景图(用TextureLoader加载一张图片赋给scene.background),做产品展示时背景不再是一片死黑,观感会立刻提升一截。

五、要素二:相机(Camera)—— 决定你从哪儿看、怎么看

如果说场景是舞台,相机就是你观众的眼睛。Three.js 提供多种相机,最常用的是透视相机(PerspectiveCamera),它模拟人眼「近大远小」的效果,最符合直觉。

js
复制
// 透视相机四个参数:fov, aspect, near, farconst camera = new THREE.PerspectiveCamera( 75, // fov:视野角度(垂直方向),越大越广角 window.innerWidth / window.innerHeight,// aspect:宽高比,通常用窗口比例,避免拉伸 0.1, // near:近裁剪面,比这更近的不渲染 1000 // far:远裁剪面,比这更远的不渲染);// 相机默认在原点(0,0,0),和立方体重合就什么都看不见// 把它沿 z 轴往后拉,才能看到放在原点的物体camera.position.z = 5;

创建透视相机需要四个参数:第一个是fov(视野角度,单位是度),决定了镜头有多广角;第二个是aspect(宽高比),通常用窗口宽高比,避免画面被拉伸;第三个是near(近裁剪面),比这更靠近相机的物体不渲染;第四个是far(远裁剪面),比这更远的物体不渲染。near和far之间的范围叫「视锥体」,只有落在这个范围内的物体才会被画出来。

这里有两个新手必踩的坑。第一,相机默认位于坐标原点(0,0,0),如果物体也在原点,两者重合,画面就会一片空白——所以务必把相机沿 z 轴往后拉,比如 camera.position.z = 5。第二,如果far设得太小,远处的物体会突然消失;如果near和far差距拉得极大,还可能引发深度精度问题,导致物体边缘闪烁。一般near 取 0.1、far 取 1000 是稳妥的起步值。除了 position,你还能用camera.lookAt(x, y, z)让相机始终朝向某个点,哪怕物体不在原点也能对准它。

六、要素三:渲染器(Renderer)—— 把 3D 算成 2D 的后期

渲染器是真正干活的那个。它在底层调用 WebGL,把「场景加相机」翻译成屏幕上的像素。

js
复制
// 创建 WebGL 渲染器,antialias 开启抗锯齿让边缘更平滑const renderer = new THREE.WebGLRenderer({ antialias: true });// 设置渲染尺寸,一般铺满整个窗口renderer.setSize(window.innerWidth, window.innerHeight);// 适配高清屏(Retina):避免画面发虚;上限设为 2 省性能renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));// 渲染器会生成一个 canvas 元素,把它挂到页面上document.body.appendChild(renderer.domElement);

一个容易忽略的细节:渲染器内部会根据devicePixelRatio 决定每个 CSS 像素对应多少物理像素。在 Retina 这类高分屏上,如果你不设置 setPixelRatio,3D 画面会明显发虚。但 pixelRatio 越高性能开销越大,通常封顶到 2 是性价比最高的做法。另外,antialias: true开启抗锯齿,能让立方体边缘更平滑,代价是略微增加开销,一般默认开着就好。最后要强调一点:渲染器自己不会自动画画,你必须手动调用renderer.render(scene, camera),而且每一帧想要画面更新,都得再调用一次——这正是动画循环存在的意义。

七、要素四:几何体加材质等于网格(Mesh)

这是「主角」登场的部分。在 Three.js 里,你看到的每一个 3D 对象都是一个「网格(Mesh)」,它由两部分拼成:几何体(Geometry)定义形状,比如立方体、球体、平面;材质(Material)定义外观,比如颜色、是否反光、贴什么图。两者用 new THREE.Mesh(geometry, material)组合,再scene.add进场景,物体就活了。

js
复制
// 几何体:定义形状(这里是一个长宽高都为 1 的立方体)const geometry = new THREE.BoxGeometry(1, 1, 1);// 材质:定义外观(MeshBasicMaterial 是自发光材质,不需要灯光也能看见)const material = new THREE.MeshBasicMaterial({ color: 0x00ff88 });// 用 Mesh 把"形状"和"外观"组合成一个可见的网格对象const cube = new THREE.Mesh(geometry, material);// 别忘了把网格加进场景,否则它只是存在于内存里、不会被渲染scene.add(cube);

这里用的是BoxGeometry(1,1,1),也就是长宽高都是 1 的立方体;材质用的是 MeshBasicMaterial,它是「自发光」材质,不需要灯光就能看见,因此特别适合做快速验证。但正因为它忽略光照,看起来会偏「平」,缺少立体感——后面讲到光照时,我们会换成能对光做出真实反应的材质。

顺带一提,几何体远不止立方体一种:SphereGeometry是球、PlaneGeometry是平面、TorusGeometry是甜甜圈、ConeGeometry是圆锥、CylinderGeometry是圆柱。材质也分好几类:MeshBasicMaterial不受光、MeshStandardMaterial是基于物理的写实材质、MeshPhongMaterial是带高光的经典材质、MeshNormalMaterial则用颜色编码法线方向,常用来调试模型朝向。刚开始你只要记住「几何体管形状、材质管外观」这条线就够用了。

八、让它转起来:requestAnimationFrame 动画循环

回到第一段完整代码,旋转的秘密藏在最后的animate函数里。这里用到的requestAnimationFrame,是浏览器提供的「逐帧调度」API,它会在每次浏览器准备重绘前(通常每秒 60 次)回调你传入的函数。

js
复制
// 定义动画函数:每一帧都会被调用一次function animate() { // 1. 预约下一帧:把自己再传进去,形成无限循环 requestAnimationFrame(animate); // 2. 每帧让立方体转一点点(rotation 是弧度制) cube.rotation.x += 0.01; // 绕 x 轴(左右翻) cube.rotation.y += 0.01; // 绕 y 轴(原地打转) // 3. 用当前相机视角,把场景画到画布上 renderer.render(scene, camera);}// 启动循环animate();

这里有个重要认知:Three.js 不会「自动」动。所有变化——旋转、位移、缩放——都需要你在每一帧里手动改属性,然后重新 render。requestAnimationFrame 保证了这个「改属性 → 重绘」的节奏跟显示器刷新同步,既流畅又省电,切到后台标签页时还会自动暂停,不会空耗 CPU。如果你之前接触过 Canvas 2D 绘图入门:从画矩形到 requestAnimationFrame 动画,会发现这里的动画循环套路一模一样:都是靠requestAnimationFrame 驱动逐帧重绘,只不过 Canvas 2D 是你自己在二维画布上画像素,Three.js 是让渲染器去算三维,殊途同归。

关于旋转速度还有个小技巧:rotation 用的是弧度制而非角度,每帧加 0.01 大约相当于每秒转 34 度,转一圈十秒出头,节奏舒适。如果你想让转速在不同刷新率的屏幕上保持一致,可以引入时间差 delta,用clock.getDelta()乘上期望速度,这样高刷屏上就不会转得飞快。换成时间差后的写法大致是:cube.rotation.y += 0.5 * delta(表示每秒转 0.5 弧度),无论 60Hz 还是 120Hz 屏幕,视觉转速都一致。

九、没有光,标准材质就是黑的:谈谈光照

现在我们把材质换成更有质感的MeshStandardMaterial(基于物理的渲染材质),这里要讲一个新手必踩的坑。

js
复制
// 把之前的基础材质换成标准材质,它能对光照做出真实反应const material = new THREE.MeshStandardMaterial({ color: 0x4f9dff, // 蓝色 metalness: 0.3, // 金属度:0 像塑料,1 像纯金属 roughness: 0.4 // 粗糙度:0 镜面,1 磨砂});// 注意:换成 Standard 材质后,如果场景里没有灯光,立方体将是全黑的

换完之后,如果你没加灯,立方体可能是全黑的!因为MeshStandardMaterial、MeshPhongMaterial这类「受光材质」必须靠灯光才能显现颜色;而前面示例里的MeshBasicMaterial是「自发光」材质,不需要光也能看见,所以它适合快速验证,但不真实。这也解释了为什么很多初学者换材质后「立方体突然变黑」——不是代码错了,是缺光。

那我们就加灯。Three.js 里常见三种光:

js
复制
// 1. 环境光:均匀、无方向地照亮全场,消除死黑,但不产生阴影const ambient = new THREE.AmbientLight(0xffffff, 0.4);scene.add(ambient);// 2. 平行光:像太阳,有固定方向,能制造明暗对比和体积感const dirLight = new THREE.DirectionalLight(0xffffff, 1.2);dirLight.position.set(5, 10, 7); // 光从右上前方打过来scene.add(dirLight);// 3. 点光源:像灯泡,从一点向四周发散,有距离衰减const pointLight = new THREE.PointLight(0xff8844, 1.5, 50);pointLight.position.set(-3, 2, 4);scene.add(pointLight);

一个实用心法:先用环境光把整体提亮到不至于死黑,再用一束平行光制造主光源的立体感,最后用点光源点缀冷暖对比。这样立方体的六个面就有了明暗层次,不再是一块平板。DirectionalLight的第二个参数是强度,PointLight 的第三个参数是照射距离(超出就衰减为 0)。如果你想要阴影,平行光还能配合 castShadow与物体的receiveShadow开启,不过那属于进阶话题,先把基础光照玩熟再说。

十、让用户拖着看:OrbitControls 交互控制

光看着转不过瘾,能不能让用户自己拖动旋转、滚轮缩放?当然可以,答案就是OrbitControls,它藏在 three 的 examples(插件)目录里。

js
复制
// 方式一:模块化引入(推荐,需配合打包工具或 importmap)import { OrbitControls } from 'three/addons/controls/OrbitControls.js';// 方式二:CDN 的 UMD 构建里,挂在 THREE.OrbitControls// const controls = new THREE.OrbitControls(camera, renderer.domElement);// 创建控制器,把相机和画布交给它托管const controls = new OrbitControls(camera, renderer.domElement);controls.enableDamping = true; // 开启阻尼,拖动有惯性,手感顺滑controls.dampingFactor = 0.05; // 阻尼系数,越小越"滑"controls.autoRotate = true; // 松手后自动旋转(可选)controls.autoRotateSpeed = 1.5; // 自动旋转速度// 关键点:开了阻尼或自动旋转后,必须在动画循环里每帧 updatefunction animate() { requestAnimationFrame(animate); controls.update(); // 处理阻尼/自动旋转的增量 renderer.render(scene, camera);}animate();

OrbitControls接管了鼠标和触摸事件:左键拖动旋转视角,右键平移,滚轮缩放。配合enableDamping 阻尼,体验非常接近那些 3D 产品展示页。注意,开启了阻尼或自动旋转后,动画循环里一定别忘了 controls.update(),否则惯性会「卡住」不生效。这个控制器还能限制缩放范围(minDistance/maxDistance)、限制垂直旋转角度(minPolarAngle/maxPolarAngle),做产品展示时避免用户把模型转到奇怪的角度,很实用。

十一、窗口缩放不变形:响应式 resize

真实项目里,用户会调整窗口大小。如果只设一次尺寸,拉伸窗口后画面会被拉伸或留白。解决办法是监听resize,同步更新相机和渲染器:

js
复制
// 窗口尺寸变化时,保持 3D 画面比例正确、不被拉伸window.addEventListener('resize', () => { // 1. 更新相机宽高比 camera.aspect = window.innerWidth / window.innerHeight; // 2. 改了相机参数后必须调用,否则投影不生效 camera.updateProjectionMatrix(); // 3. 更新渲染器画布尺寸 renderer.setSize(window.innerWidth, window.innerHeight);});

这里有个关键点:改了相机的aspect之后,必须调用camera.updateProjectionMatrix(),否则新的宽高比不会生效,画面照样变形。这跟前面「改了相机参数都要刷新投影矩阵」是一个道理。响应式处理看着简单,却是几乎所有上线项目必备的一环,漏掉它,用户一拉伸窗口,立方体就「被压扁」了。

十二、整合:一个更接近真实项目的骨架

把光照、控制器、响应式全加上,下面是更接近真实项目的完整骨架(聚焦 JS 逻辑,HTML 壳省略,前缀引入方式与前面一致):

js
复制
import * as THREE from 'three';import { OrbitControls } from 'three/addons/controls/OrbitControls.js';// —— 场景 ——const scene = new THREE.Scene();scene.background = new THREE.Color(0x0b0b1a);// —— 相机 ——const camera = new THREE.PerspectiveCamera( 60, window.innerWidth / window.innerHeight, 0.1, 1000);camera.position.set(3, 3, 5);// —— 渲染器 ——const renderer = new THREE.WebGLRenderer({ antialias: true });renderer.setSize(window.innerWidth, window.innerHeight);renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));document.body.appendChild(renderer.domElement);// —— 物体:几何体 + 材质 ——const geometry = new THREE.BoxGeometry(2, 2, 2);const material = new THREE.MeshStandardMaterial({ color: 0x4f9dff, metalness: 0.4, roughness: 0.3});const cube = new THREE.Mesh(geometry, material);scene.add(cube);// —— 光照 ——scene.add(new THREE.AmbientLight(0xffffff, 0.5));const keyLight = new THREE.DirectionalLight(0xffffff, 1.2);keyLight.position.set(5, 8, 6);scene.add(keyLight);// —— 控制器 ——const controls = new OrbitControls(camera, renderer.domElement);controls.enableDamping = true;// —— 动画循环 ——function animate() { requestAnimationFrame(animate); cube.rotation.y += 0.005; // 缓缓自转 controls.update(); // 处理阻尼/自动旋转 renderer.render(scene, camera);}animate();// —— 响应式 ——window.addEventListener('resize', () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight);});

十三、性能与进阶:往哪走才不迷路

跑通之后,你可能会想:这玩意到底能有多快?答案是,WebGL 本身已经能榨干 GPU,渲染几万乃至几十万个简单三角形都不在话下。但如果你的场景要做海量粒子、复杂物理模拟或实时骨骼动画,CPU 上的 JavaScript 计算可能成为瓶颈。

这时候就值得了解下WebAssembly 入门:让前端也能跑接近原生的性能(instantiateStreaming 与线性内存)。把计算密集的部分——比如碰撞检测、流体模拟——编译成 WASM 来跑,Three.js 只负责把结果画出来,性能上限会高很多。当然,绝大多数业务场景用纯 JS 版本就足够,别过早优化。

再聊两个工程上的小建议。第一,3D 画布通常要配一个深色、沉浸式的页面背景才好看。与其到处写死颜色,不如用 CSS 变量(Custom Properties)实战:一行 var() 搞定全局主题切换 的方式,把背景色、强调色都定义成 CSS 变量,将来换肤或做深色调只改一处。第二,three.js 的库文件不小,如果是做对外展示的 demo,最好考虑性能与离线。参考 PWA 离线应用实战:Service Worker 缓存策略与 manifest 配置,把 three.js 和你的脚本用 Service Worker 缓存下来,用户第一次加载后,断网也能继续把立方体转下去——对展会、现场演示特别有用。

十四、新手最常翻的车:四要素排查清单

写到这,我把新手最常在四个要素上翻的车列一遍,方便你对照排查:

  • 画面全黑或空白:多半是相机没挪位置(和物体在原点重合),或用了受光材质却没加灯,或camera.position.z没设导致看不见物体。
  • 物体是纯黑方块:MeshStandardMaterial没灯光就是黑的,记得加光,或先用MeshBasicMaterial验证。
  • 画面被拉伸变形:相机aspect没跟随窗口,或resize时忘了updateProjectionMatrix。
  • 拖动没惯性、自动旋转不动:开了damping/autoRotate却没在循环里调controls.update()。
  • 边缘锯齿明显:渲染器没开antialias,或setPixelRatio没设导致高分屏发虚。
  • 高刷屏转得飞快:旋转量写死每帧 0.01,没用时间差 delta归一化。
  • 中文注释乱码或变方块:确认 HTML 文件是 UTF-8 编码,并在 <head>里加了<meta charset="utf-8">。

十五、写在最后

回头看,我们从「原生 WebGL 太难」出发,用场景、相机、渲染器、几何体加材质这四样东西,搭出了一个会转、能拖动、响应式、有光照的 3D 立方体。整个过程不到一百行代码。

更重要的是,你收获了一套思维框架:以后无论场景多花哨——加载模型、做粒子、加后期特效——你都知道先摆场景、架相机、开渲染器、造物体,再用动画循环驱动变化。四要素永远是那个不变的锚。Three.js 的上限远不止一个立方体,模型加载、阴影、后期处理、物理引擎、着色器编程,每一扇门都值得推开。但万丈高楼平地起,今天这个旋转的方块,就是你踏入 Web 3D 世界的第一块砖。把它跑起来,改改颜色、调调速度,玩熟了,再往前走。

i12s6wy0zbi12s6wy0zb
评论

评论列表共有0条评论

发表评论发布评论后审核成功,即可显示在列表中...

文章1
粉丝0
获赞0
私信