☰
Three.js实战:从零搭建可交互Web3D场景的完整指南
2026/10/28 21:46:17 网站建设 项目流程

最近在折腾一个叫 t3code 的项目,说是项目,其实最初只是一个念头:我想在浏览器里跑一个不依赖后端、打开页面就能看的 3D 场景,能转、能缩放、能切换视角,最好还能塞进几个自己建模的物件。市面上做 Web 3D 的方案不少,但我最后选了 Three.js,并且把整个工程命名为 t3code 作为代号。这篇文章就是把 t3code 从零到能跑的完整过程记录下来,包括我踩过的坑、改过的参数和最后留下来的工程习惯。如果你正打算入门前端 3D,或者已经写了几个 demo 但总感觉哪里没理顺,这篇应该能帮你省掉不少摸索时间。

1. 为什么我会做 t3code 这个项目:从需求到选型

1.1 初衷:一个“纯粹”的 3D 展示页

我当时的想法很具体:不要后台、不要数据库、不要复杂的构建链路,只要一个静态页面,用户打开后能立刻看到一个可交互的 3D 场景。这个场景里要有地面、有主体物件、有灯光、有阴影,最好还能用鼠标拖拽旋转,滚动缩放。这个需求在企业官网、产品展示、数字孪生大屏、甚至个人作品集里都特别常见,属于“看起来很炫,但一旦做起来就全是细节”的那种任务。

为什么强调“纯粹”?因为很多团队一上来就上重型方案,比如 Unity 的 WebGL 导出、或者用 react-three-fiber 这种基于 React 的封装,然后引入一批状态管理、动画库。不是说这些方案不好,而是对于一个以展示为主、不需要复杂业务逻辑的场景,直接把 Three.js 当作核心库、其他东西按需引入,反而更可控。t3code 这个项目就是在这种思路下立项的:保持最小依赖,保持最大掌控力。

1.2 技术方案对比:为什么不是 Unity、不是 R3F

我简单做了一轮对比。Unity 导出的 WebGL 包体积大,加载慢,而且场景里的交互逻辑被封装得很深,想改一个细节得回 Unity 编辑器重新导一次。R3F(react-three-fiber)本身很好用,但对团队有 React 经验的要求,而且它的声明式写法会让初学者觉得“我好像没写 Three.js”,一旦遇到报错,排查链路直接被拉长。原生 Three.js 的好处是概念清晰:场景、相机、渲染器、物体、灯光、材质,每一层都能直接理解和控制。坏处是样板代码多一点,但这点样板代码恰恰是理解 Web 3D 底层逻辑最划算的学费。

所以 t3code 最终的技术栈是:Vite + TypeScript + Three.js。Vite 负责开发服务器和构建,TypeScript 负责类型约束,Three.js 负责 3D 渲染。没有其他花哨的东西。

1.3 项目目录结构:一开始就分好层

我见过太多 Three.js 项目写着写着就变成一个巨大的 app.ts,所有代码堆在一起。t3code 从第一天就按职责分了目录,后期加功能特别顺手。

t3code/ ├─ index.html ├─ package.json ├─ tsconfig.json ├─ vite.config.ts └─ src/ ├─ main.ts # 入口,负责初始化场景 ├─ scene/ │ ├─ createScene.ts # 创建场景 │ ├─ createCamera.ts # 创建相机 │ └─ createRenderer.ts # 创建渲染器 ├─ objects/ │ ├─ ground.ts # 地面 │ └─ mainCube.ts # 主物体 ├─ lights/ │ └─ setupLights.ts # 灯光配置 └─ systems/ ├─ loop.ts # 动画循环 └─ resize.ts # 窗口自适应

这样一个目录结构带来的直接好处是:想改灯光的时候不用在几千行代码里找,想换物体的时候直接改 objects 目录。后面我会按照这个结构一步步带大家把每个文件填满。

2. 环境准备与工程化搭建:Vite 是最省心的起点

2.1 初始化 Vite + TypeScript 工程

t3code 的项目初始化我用的是 Vite 官方命令行工具。打开终端,执行下面的命令:

npm create vite@latest t3code -- --template vanilla-ts

这个命令会创建一个基于 TypeScript 的 Vite 模板,里面带有 tsconfig.json 和基本的目录结构。我特意没用 Vue 或 React 模板,因为 t3code 不需要框架层,用框架模板反而会引入一堆用不到的东西。

创建完以后进入目录,安装 Three.js:

cd t3code npm install three

这里有一点需要说明:新版 Three.js 已经内置了 TypeScript 类型声明,所以不需要额外安装 @types/three。如果你在网上搜到老教程让你装 @types/three,多半是因为版本较旧。装完以后可以打开 package.json 确认一下 three 的版本号,t3code 做的时候是 0.1xx 版本,API 已经相当稳定。

2.2 跑通第一个渲染窗口:三角形比立方体更合适

很多人入门 Three.js 的第一反应是创建一个立方体,但我建议先画一个三角形。为什么?因为三角形是 WebGL 的最小单元,立方体本质上也是由三角形组成的。能看到三角形在屏幕上出现,说明整个渲染链路已经通了,后面的东西都是在这个基础上堆叠。

先清空 src/main.ts,写入最基础的代码:

import * as THREE from 'three'; const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000 ); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); document.body.appendChild(renderer.domElement); const geometry = new THREE.BufferGeometry(); const vertices = new Float32Array([ 0, 0, 0, 1, 0, 0, 0, 1, 0 ]); geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3)); const material = new THREE.MeshBasicMaterial({ color: 0x3b82f6 }); const triangle = new THREE.Mesh(geometry, material); scene.add(triangle); camera.position.z = 3; function animate() { requestAnimationFrame(animate); renderer.render(scene, camera); } animate();

然后在项目根目录执行npm run dev,浏览器打开 Vite 给出的本地地址,如果能看到一个蓝色三角形,恭喜你,t3code 的第一步已经踩实了。这里我不急着加轨道控制,先把基础链路看清楚,再一步步往上加东西。

2.3 开发服务器与构建输出:习惯比命令更重要

Vite 的开发服务器有热更新,改代码以后页面自动刷新。这很爽,但有个习惯要养成:改完代码以后瞄一眼浏览器控制台有没有报错。Three.js 很多错误是运行时才能发现的,比如几何体属性缺失、材质参数类型不对,这些不一定在编辑器中爆红,但控制台一定会输出警告。把这些警告当回事,能省下后面大量的排查时间。

构建方面,直接npm run build,Vite 会输出到 dist 目录。如果后续要部署到服务器,需要留意一点:静态资源路径。默认 Vite 的 base 是/,如果你把 t3code 部署在子路径下,比如https://example.com/t3code/,就要在 vite.config.ts 里设置 base:

import { defineConfig } from 'vite'; export default defineConfig({ base: './' });

用相对路径是静态展示页最稳妥的方案,丢到哪个目录都能跑。

3. 场景、相机与渲染器:先把“铁三角”搞清楚

3.1 Scene:所有物体的容器

Three.js 里的 Scene 本质上就是一个容器,所有可见的、不可见的物体都挂在它下面。它的核心能力是层级管理:你可以把多个物体放进一个 Group,再把 Group 加进 Scene。对 Group 做变换时,里面的物体会跟着一起动,就像文件夹管理文件一样。

t3code 里我用createScene.ts单独管理场景的创建:

import * as THREE from 'three'; export function createScene(): THREE.Scene { const scene = new THREE.Scene(); // 背景色很重要,决定了整个页面的氛围 scene.background = new THREE.Color(0x0b0b0f); return scene; }

背景色这里我用了一个接近黑的深色,目的是让后面加的暖色灯光更有对比感。如果你做的是白天场景,用 0x87ceeb 之类浅色会有完全不同的观感。Scene 本身还能添加雾效,通过scene.fog = new THREE.Fog(0x0b0b0f, 10, 50)可以让远处的物体渐渐隐入背景色,质感和层次感一下就出来了。

3.2 Camera:透视相机与正交相机的取舍

t3code 默认使用 PerspectiveCamera(透视相机),这是最符合人眼直觉的相机,近大远小,最适合产品展示和场景漫游。初始化时四个参数需要理解透彻:

  • fov(视野角度):用角度值表示,比如 75 代表垂直方向能看到 75 度范围内的场景。数值越大,看到的东西越多,但透视变形也越强,容易产生“鱼眼感”。
  • aspect(宽高比):等于画布宽度除以高度,一般设置为window.innerWidth / window.innerHeight。
  • near 和 far(近裁剪面和远裁剪面):相机只能看到这两个距离之间的物体。near 太小会导致深度精度问题,far 太大又容易引发闪烁,常规场景 near 用 0.1、far 用 100 就够。

这里有一个很重要但新手容易忽略的点:改完相机位置后,需要调用camera.lookAt()指定相机朝向。Three.js 的相机默认看向 Z 轴负方向,如果你把相机放在 (0, 0, 5) 而物体在原点,默认是能看到的;但如果你把相机挪到侧面,比如 (5, 3, 5),不调用 lookAt 时画面里可能什么都看不到。

3.3 Renderer:画布尺寸、像素比和动画循环

Renderer 是整个渲染过程的总调度。t3code 里我这样创建渲染器:

import * as THREE from 'three'; export function createRenderer(): THREE.WebGLRenderer { const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; document.body.appendChild(renderer.domElement); return renderer; }

有几个细节值得说。setPixelRatio是为了适配 Retina 屏幕。如果你不设置,在高分屏上渲染结果会发虚;如果直接设置成window.devicePixelRatio,超过 2 的屏幕会带来多余的 GPU 开销。取Math.min(devicePixelRatio, 2)是一个性能和清晰度的平衡点,也是 Three.js 官方示例里的常见做法。

shadowMap开关是阴影的关键。Three.js 默认不开启阴影,必须把shadowMap.enabled置为 true,然后再配合灯光和物体的 castShadow/receiveShadow 属性才能真正出阴影。我习惯用PCFSoftShadowMap,柔和的边缘比硬阴影好看太多。

动画循环我用requestAnimationFrame实现。它会在浏览器每次重绘前执行回调,帧率通常 60fps,到高刷屏上可能更高。所有需要变化的元素,比如物体旋转、相机移动、光照强度变化,都应该放在这个回调内部:

function animate() { requestAnimationFrame(animate); // 在这里更新物体的状态 renderer.render(scene, camera); }

一个很容易犯的错误是:把动画循环写成了setInterval(render, 16.7),这样在高刷屏上会掉帧,在低帧率设备上又会卡顿。requestAnimationFrame是浏览器层面的最优调度,没有理由不用它。

4. 模型、材质与光照:让 t3code 真正“看得见”

4.1 用几何体和材质搭出基础物件

三角形能跑通渲染链路,但离“作品”还很远。t3code 的第一个正式场景里,我放了一个立方体作为主视觉,再加一个圆环作为点缀。选立方体是因为它的明暗关系最容易观察:每个面朝向不同,接受光照后的亮度差异会非常明显,很适合用来调光和调材质。

创建主物体的代码放在mainCube.ts里:

import * as THREE from 'three'; export function createMainCube(): THREE.Mesh { const geometry = new THREE.BoxGeometry(1.5, 1.5, 1.5); const material = new THREE.MeshStandardMaterial({ color: 0xff7043, roughness: 0.35, metalness: 0.05 }); const cube = new THREE.Mesh(geometry, material); cube.position.set(0, 1, 0); cube.castShadow = true; cube.receiveShadow = true; return cube; }

MeshStandardMaterial是 Three.js 里最常用的 PBR 材质,可以理解为“基于物理的渲染”。和基础材质MeshBasicMaterial的区别是:基本材质完全不受光照影响,像一张直接贴上去的贴纸;标准材质则需要有光才能看见,roughness 控制粗糙度(值越大,表面越糙,高光越散),metalness 控制金属感(值越大,物体越像金属)。这个材质在暗场景里的质感比 Basic 好太多了,代价只是多一点计算量,现代设备完全扛得住。

圆环可以用来摆姿态或者做环状结构,t3code 里我用它做了一个半透明的环绕装饰:

const ringGeometry = new THREE.TorusGeometry(2, 0.08, 16, 100); const ringMaterial = new THREE.MeshStandardMaterial({ color: 0x64b5f6, roughness: 0.5, metalness: 0.8, transparent: true, opacity: 0.6 }); const ring = new THREE.Mesh(ringGeometry, ringMaterial); ring.rotation.x = Math.PI / 2; ring.position.y = 1;

透明材质在 Three.js 里要注意渲染顺序问题,不过 t3code 这个场景简单,透明度不高,暂时没有出现遮挡错乱。后面如果要做复杂透明材质,可能要考虑depthWrite的设置,这里先不展开。

4.2 光的种类与布光逻辑

没有光的场景,PBR 材质会显示为纯黑,所以在 t3code 里我把灯光当作与建模同等重要的环节来对待。我使用了两个主光源加一个环境光:

import * as THREE from 'three'; export function setupLights(scene: THREE.Scene): void { // 环境光:保证物体的暗面不至于完全漆黑 const ambient = new THREE.AmbientLight(0x404060, 0.6); scene.add(ambient); // 主光源:平行光,模拟太阳,产生明显的明暗交界 const mainLight = new THREE.DirectionalLight(0xffedcc, 1.2); mainLight.position.set(5, 8, 5); mainLight.castShadow = true; scene.add(mainLight); // 补光:点光源,放在主光的对侧,把阴影里的细节拉出来 const fillLight = new THREE.PointLight(0x6688ff, 0.5); fillLight.position.set(-3, 2, -4); scene.add(fillLight); }

这里选择的DirectionalLight没有位置的概念,只有方向的概念,所以在设置 position 以后,它只是把光线从该位置指向原点。平行光的特点是光线彼此平行,照出来的阴影边界干净,非常适合做场景的主光源。PointLight则像一个灯泡,从中心向四周发射光线,适合做局部的补光。

t3code 第一天跑到这里时,我盯着屏幕看了一会儿,觉得“好像有点东西了”。主光把立方体的右上角打得发白,蓝色的补光在暗部泛起一层冷色,半透的圆环横在立方体上方,整张画面的层次感一下子出来了。这种实时的反馈既直观也挑人:参数调得不对,比如主光强度调成 5,画面会瞬间过曝,白花花的一片,什么都看不清。所以调光的时候养成习惯,改一个变量保存刷新,不要一口气改五六个参数然后一脸茫然。

4.3 OrbitControls:让画面动起来

静态渲染图做得再好,也不是 Web 3D 的卖点。交互才是。t3code 里我引入了 OrbitControls,也就是轨道控制器,让用户能自由旋转和缩放视角。它的引入方式比较特殊,不通过 npm 包安装,而是从 three 的附加模块导入:

import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.05; controls.minDistance = 2; controls.maxDistance = 20; controls.target.set(0, 1, 0);

这里最值得说的是enableDamping。如果不开它,拖拽结束的那一刻画面就停住,手感非常生硬;开了之后,视角会有惯性缓冲,平滑地滑向停止位置。你需要在动画循环里加上controls.update(),阻尼效果才会生效,不然没有用。

我还限制了相机与目标点的距离范围,避免用户缩到物体里面去,或者缩得太远导致场景变得极小而找不到。target.set(0, 1, 0)和主物体的位置一致,这样一进场景,相机就会自动看向主物体。

5. 踩坑记录:开发 t3code 时真实的翻车瞬间

5.1 物体“消失”了:FOV 和相机距离的关系

t3code 开发到一半时,我把主立方体的尺寸从 1.5 改到 0.2,然后刷新,发现画面里什么都看不到了。第一反应是代码哪里写错了,检查半天没发现问题。后来把相机距离从 5 改成 1,物体又出现了。

原因其实很简单:PerspectiveCamera 的 FOV 是 75,相机在 Z 轴 5 的位置,物体只有 0.2 大小,在屏幕上只占不到几个像素,加上背景是深色,自然就“看不见”了。这不是渲染错误,是视觉误差。

这个坑给我的教训是:在调整物体尺寸的时候,要意识到相机视野和距离是一个整体系统。不会有一个参数能独立适配所有情况。反过来,如果你把物体做得很大,相机离得很近,又会出现“物体把整个画面撑满,甚至穿模到相机后面”的情况。此时要么拉远相机,要么缩小物体,没有捷径。

5.2 纹理加载失败:路径与跨域

有一次我想给立方体贴一张木纹纹理,用TextureLoader从本地加载图片,结果控制台报错,纹理显示为一片紫色。排查后发现是路径问题。Vite 开发服务器中,如果你把图片放在public目录下,路径要以/开头引用,比如/textures/wood.jpg;如果你放在src/assets里,则要用 import 导入,让 Vite 处理路径。新手最容易犯的错是放到了 src 里还用字符串路径去找,结果找不到文件。

如果是从 CDN 加载纹理,还需要考虑跨域问题。Three.js 的 TextureLoader 基于网页的图片加载机制,如果图片服务器没有允许跨域的头信息,加载会失败。解决办法有几种:让服务端加上 CORS 响应头;或者把图片下载到本地再打包。t3code 里为了避免这种麻烦,所有纹理都走本地目录。

5.3 动画循环与内存泄漏:不断 add 却没有 remove

还有一个比较隐蔽的坑:有些物体或纹理在使用完之后没有释放内存。WebGL 的内存管理和普通 JavaScript 不太一样,它需要显式地调用geometry.dispose()和material.dispose()来告诉 GPU 这些资源可以回收了。

我在 t3code 里做过一个实验:每秒钟向场景里添加一个新的随机颜色小球,持续几分钟后页面开始卡顿。排查发现,虽然小球是随机删除的,但删除的时候只从 scene 里移除,没有调用 dispose,导致 GPU 上的缓冲区和纹理一直残留。虽然这个实验是为了测试,但真实项目里做动态生成物体时,同样的逻辑错误也一样会发生。

正确的删除方式应该是:

function removeObject(obj: THREE.Mesh) { scene.remove(obj); obj.geometry.dispose(); (obj.material as THREE.Material).dispose(); }

别小看这三行代码,动态场景和长期运行的页面里,它决定了你跑半小时之后是丝般顺滑还是卡成幻灯片。

6. 性能优化与扩展思路:跑通后还能怎么玩

6.1 静态资源异步加载与加载管理器

t3code 看似静态,但模型、贴图都是资源。页面刚打开时如果一次性加载十几个大模型,白屏时间会相当感人。Three.js 提供了 GLTFLoader 加载外部模型的方案,同时有LoadingManager可以跟踪加载进度,做出加载百分比提示。

一个比较实用的懒加载思路是这样:先用很轻量的示意几何体把场景占位渲染出来,等用户交互或进入特定区域时,再去加载真正的精细模型。这个方案在很多 3D 展示页里都有应用,t3code 后续升级也可以走这条路。

Vite 自带按需加载能力,配合动态 import,可以做到:点击按钮才加载某个模块的代码和关联资产。这样初始 bundle 体积可以压得很低,加载策略也会干净很多。

6.2 帧率监控与最小化重绘

开发过程中,我习惯在页面上显示实时帧率,用来快速判断场景的渲染开销。做法很简单,在动画循环里累加帧数,每秒钟计算一次 FPS:

let frameCount = 0; let lastTime = performance.now(); function loop() { requestAnimationFrame(loop); frameCount++; const now = performance.now(); if (now - lastTime >= 1000) { console.log('FPS:', frameCount); frameCount = 0; lastTime = now; } renderer.render(scene, camera); }

如果帧率长期低于 30,就要考虑优化了:降低setPixelRatio、减少 shadowMap 的纹理大小、合并几何体、或者用 LOD(Level of Detail,根据距离切换不同精度的模型)方案。t3code 目前的场景物体数量少,帧率很稳定,但这个监控逻辑我始终保留着,后面每加一个新物件,我都能第一眼看出它对性能的影响。

6.3 模型导入、动画、后期处理的扩展方向

跑通基础功能以后,t3code 的扩展空间其实很大。最重要的方向是用 GLTFLoader 导入真正建模软件做的模型,Blender 里建一棵树或者一个机器人,导出为 glb 格式后放到 public/models 目录,然后:

import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; const loader = new GLTFLoader(); loader.load('/models/robot.glb', (gltf) => { scene.add(gltf.scene); }, undefined, (err) => { console.error('模型加载失败:', err); });

有了外部模型,场景的真实感会立刻提升一个档次。配合动画系统,可以做模型的肢体摆动、机械臂旋转。后期处理方面,Three.js 的 EffectComposer 支持添加 Bloom 辉光、Film 噪点、Vignette 暗角等效果,把 3D 场景的视觉氛围再往上推一层。

我自己在实际操作中的体会是:Web 3D 项目最怕的不是不会用某个 API,而是没有一个清晰的项目骨架。t3code 之所以能顺利跑下来,很大程度上是因为我把场景、相机、渲染器、物体、灯光、循环这些事情从一开始就拆开了。等你把这些基础单元都熟练了,后面再上模型、动画、后期处理,都是在同样的骨架上做加法,不会出现“加一个新功能就重构一遍”的窘境。如果你也要起一个自己的 3D 项目,建议不要急着堆功能,先把这套骨架搭稳。

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

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

立即咨询