t3code 是我最近在维护的一个 Three.js 项目代号,整个仓库里收集的是各种可复用的 3D 场景示例,从最基础的盒子渲染到稍微复杂一点的交互模型加载都有。起这个名字就是因为 Three.js 的 "three" 读起来像 "t3",再加上 code,方便自己记,后来也顺手拿它当项目文件夹名,没想到一用就用了大半年。
这个项目主要解决一件事: 让前端开发者不用被原生 WebGL 劝退,而是通过 Three.js 快速做出能看、能点、能玩的 3D 页面。如果你正打算从零接触 3D 开发,或者已经在用 Three.js 但总感觉场景一复杂就发蒙,那这篇文章里的设计思路、参数计算和调试方法应该对你有帮助。我会把 t3code 里我自己踩过的一些坑和验证过的操作细节都摊开讲,尽量让小白也能照着写。
1. 整体设计与技术选型
1.1 为什么选 Three.js 而不是原生 WebGL
我刚开始做 3D 时也纠结过这个问题: 到底是直接撸 WebGL API 还是用 Three.js。后来在 t3code 里写了几个对比小例子,体验差别非常明显。原生 WebGL 需要考虑缓冲区、着色器编译、矩阵变换,一个旋转立方体就得几十行起,而 Three.js 帮你把这些底层细节都封装好了,Scene、Mesh、Camera、Renderer 几个核心对象一组合,三五行代码就能出画面。
但这不代表 Three.js 是个黑盒。它只是把 GPU 管线的常规操作模板化,比如你依然需要理解顶点、片元、光照这些概念,只不过由 Three.js 帮你生成默认着色器。对于产品页面、数据可视化、3D 展厅这类场景,Three.js 的性价比是最高的。t3code 的目标不是绕开复杂度,而是让你在可控的复杂度内把事做成。
1.2 t3code 的功能规划与场景设计
在设计 t3code 的时候,我先列了几个必须满足的基础能力: 场景得能旋转,模型得能加载,鼠标点一下物体得有反馈。这三个能力几乎覆盖了 80% 的 Web 3D 需求,所以我把它拆成了三个独立模块,每个模块都能单独复用。
第一个模块是基础场景,包括渲染器、透视相机、环境灯光和地面网格;第二个模块是模型加载,支持 glTF 格式的模型和纹理;第三个模块是射线拾取,通过鼠标点击事件选中场景里的物体并做出反应。这样的结构有点像搭积木,每个模块之间通过场景对象衔接,后续新增功能时不会互相牵制。你如果自己搭项目,也建议先按"能看、能动、能交互"这个顺序去规划,比一上来就堆一堆高级效果要稳得多。
2. 核心细节解析与实操要点
2.1 三维坐标系与相机参数计算
刚接触 Three.js 时最容易懵的就是坐标系。Three.js 默认使用右手坐标系: X 轴向右,Y 轴向上,Z 轴朝屏幕外。也就是说,在场景里放置物体时,正 Z 方向是朝自己,负 Z 方向是朝向屏幕里面。这是个很基础但很容易搞反的点,我在 t3code 里特意在场景中心放了一个轴辅助,就为了时刻提醒自己。
相机参数方面,透视相机是最常用的,它模拟人眼的"近大远小"效果。构造函数是 PerspectiveCamera(fov, aspect, near, far)。fov 是视场角,单位是度,一般设置在 45 到 75 之间,fov 越大,看到的范围越广,但是物体显得越小;aspect 是画布的宽高比,通常是 window.innerWidth / window.innerHeight;near 和 far 是近裁剪面和远裁剪面,意思是在这个范围内的物体才会被渲染。
实际使用时有几个细节需要注意。比如 near 和 far 的差值不要太大,否则会出现深度精度问题,远处物体闪烁或者看不清。一个常见经验是把 far 设成场景最远物体的 2 到 3 倍就行,没必要给个十万八万的。t3code 里的默认参数是: fov 60,aspect 根据窗口动态计算,near 0.1,far 1000。如果场景中需要看到很远的天空盒,可以适当把 far 拉大,但一定要同时测试地面的遮挡关系,防止视觉错误。
2.2 场景组织与性能优化要点
性能优化往往是新手最容易忽略的。刚开始做 t3code 时我直接把十个模型全丢在场景里,动画一跑就掉帧,后来才明白每次绘制都是要付出代价的。
Three.js 的渲染是按 draw call 来计算的,每个 Mesh 单独加入场景时,渲染器需要发送多次绘制指令。一个可行的办法是使用 InstancedMesh,把大量相同几何体的顶点数据合并到一次绘制里完成。比如场景里有 100 个同样的小方块,用普通 Mesh 需要 100 次 draw call,用 InstancedMesh 只需要 1 次。对于大量重复物体这个优化效果非常明显。
另一个优化点是纹理压缩和加载策略。t3code 里为了避免模型加载时白屏,我会用 RGBELoader 加载环境贴图,同时把大纹理图拆成 mipmap 级别,让 GPU 在更远距离显示低分辨率贴图。这些操作不会让画面变差,反而能大幅降低 GPU 压力。记住一个原则: 不是所有物体都需要高精度的贴图,离得远的、小的物体完全可以用压缩纹理。
3. 实操过程与核心环节实现
3.1 初始化项目与基础场景搭建
我建议你用 Vite 加 npm 来搭项目,因为 Vite 的即时冷启动对开发体验提升很大。先初始化一个 vanilla 项目,然后安装 three,代码里通过 npm 包的方式引入即可。不需要什么复杂的脚手架,一个 index.html 加一个 src 目录就够用。
下面是最基础的一段初始化代码:
import * as THREE from 'three'; const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000); camera.position.set(5, 5, 5); camera.lookAt(0, 0, 0); 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(1, 1, 1); const material = new THREE.MeshStandardMaterial({ color: 0x4a90d9 }); const cube = new THREE.Mesh(geometry, material); scene.add(cube); const light = new THREE.DirectionalLight(0xffffff, 1); light.position.set(3, 5, 4); scene.add(light); function animate() { requestAnimationFrame(animate); cube.rotation.x += 0.01; cube.rotation.y += 0.01; renderer.render(scene, camera); } animate();这段代码跑起来后,你会在窗口中间看到一个旋转的立方体,光源从右上方向打过来,立方体有阴影和立体感。l
这里有一个容易踩的坑: 如果直接使用 MeshBasicMaterial,它不受光照影响,画面会显得很平,没有立体感。t3code 里我用的都是 MeshStandardMaterial,它是基于物理的材质,对光照更真实,但需要场景里至少有一个光源,否则物体会是全黑的。很多新手第一次跑看到黑色物体以为代码错了,其实就是因为没加光源。
3.2 添加交互控制与动画
场景搭好之后,最自然的需求就是让用户能自由旋转视角。这一步我推荐使用 three/examples/jsm/controls/OrbitControls 这个控制器,它实现了鼠标拖拽旋转、滚轮缩放和右键平移,是我在这个项目里最喜欢的自带扩展。
接入方式也简单,在动画面循环里更新控制器的状态:
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; function animate() { requestAnimationFrame(animate); controls.update(); renderer.render(scene, camera); }enableDamping 开启后,视角移动会带有一点惯性,手感更顺滑,但别忘了 update() 方法要在渲染循环里调用。还有一个细节是,我不建议把阻尼系数调太低,0.03 到 0.08 之间比较舒服,太大会显得拖泥带水,太小又像没有阻尼。
动画这一块,除了在循环里手动修改 rotation 以外,还可以用 Clock 对象来获取时间差,这样可以保证动画速度在不同帧率下基本一致。比如让立方体每秒旋转 30 度,而不是每帧旋转固定角度。代码大概是这样:
const clock = new THREE.Clock(); function animate() { const delta = clock.getDelta(); cube.rotation.y += Math.PI / 6 * delta; renderer.render(scene, camera); }这样即使屏幕刷新率从 60Hz 变成 144Hz,动画速度也不会变化。性能好的时候更流畅,性能差的时候只是掉帧,但不会疯转。
3.3 加载外部模型与事件拾取
t3code 里另一个重量级功能是加载外部模型。目前最通用的格式是 glTF,它是三维行业的交换格式,体积小、加载快。Three.js 里有现成的 GLTFLoader,需要从 examples 里引入。同时要配合加载器设置路径,否则模型内部的纹理容易加载失败。
代码示例:
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; const loader = new GLTFLoader(); loader.load('/models/robot.gltf', (gltf) => { scene.add(gltf.scene); });加载器回调里拿到的是 gltf,它内部包含 scene、animations 等属性。注意要把 gltf.scene 加到主场景,而不是把整个 gltf 对象加进去。这是新手最容易错的一步,我一开始写漏了,结果页面什么都没显示。
事件拾取则要用到 Raycaster 和 Vector2。思路是: 把鼠标点击的位置转换成标准化设备坐标(NDC),然后用射线检测场景里的物体。你可以这样实现:
const raycaster = new THREE.Raycaster(); const pointer = new THREE.Vector2(); window.addEventListener('click', (event) => { pointer.x = (event.clientX / window.innerWidth) * 2 - 1; pointer.y = -(event.clientY / window.innerHeight) * 2 + 1; raycaster.setFromCamera(pointer, camera); const intersects = raycaster.intersectObject(cube); if (intersects.length > 0) { cube.material.color.set(0xff5533); } });这里把鼠标坐标从像素值转换到 -1 到 1 的区间,y 轴要取反,因为屏幕坐标和 NDC 坐标的 y 方向是相反的。我经常看到有人掉了这个负号,导致点击永远对不上,排查半天。还有一点,如果场景里物体比较多,建议用 intersectObjects(scene.children, true) 并开启递归检测,但要注意性能,物体太多时可以分组检测或者手动指定被检测的数组。
4. 常见问题与排查技巧实录
4.1 场景渲染不出来或黑屏
这是 t3code 群里被问得最多的问题。遇到黑屏,先不要急着改代码,从几个固定方向查。
第一,检查相机位置。如果相机位置是 (0, 0, 0),而且物体也在这个点附近,相机可能嵌在物体内部,看起来就像画面一片黑或者模糊。把相机往后拉,移动到 (5, 5, 5) 再 lookAt 原点就能解决。第二,检查是否有光照。之前提到 MeshStandardMaterial 需要光源,没有光源就是全黑。第三,检查渲染器的 canvas 是否被 CSS 遮住了。最常见的是 html 和 body 默认 margin 为 8px,或者画布被某个 div 盖住,看着像没渲染出来。
我建议在场景里先加一个 AxesHelper,能看到坐标轴就说明渲染链路通了:
const axesHelper = new THREE.AxesHelper(5); scene.add(axesHelper);坐标轴正常显示后,再逐步排查物体、灯光和模型。
4.2 模型加载失败或纹理丢失
模型加载失败最典型的现象是控制台报了 404,或者场景里只有乱七八糟的几何体但没有贴图。这通常跟资源路径有关。GLTF 文件内部会引用外部纹理图片,加载器默认基于传入的 URL 目录去拼路径。如果你项目用的是 Vite,需要把模型资源放到 public 目录下,然后使用 /models/xxx.gltf 这种绝对路径,而不是相对路径。
另外要留意 glTF 的 variant 问题。有些模型文件是 PBR 格式,shader 里用了金属度、粗糙度贴图,如果你的 Three.js 版本比较旧,可能不支持部分扩展。我建议把 Three.js 升到最新稳定版,并且在加载后打印 gltf.parser 的信息,能看出扩展加载情况。
如果模型加载成功但显示出来是白色的,多半是没有环境贴图。glTF 模型往往依靠环境光照才能体现反射效果。可以在场景里加一个简单的环境光或者设置 renderer.environment,让模型有基本的明暗过渡。
4.3 坐标与交互偏移问题
坐标和交互偏移是另一个高频问题。最直接的表现是: 鼠标点击物体时,射线拿到的位置和视觉位置对不上。原因基本都在 NDC 转换那一套。
你要检查两个地方: 一是 canvas 的位置和尺寸。如果你的渲染器画布不是全屏,而是处于某个容器内,那 event.clientX 和 window.innerWidth 直接换算就会出错,因为 canvas 相对于窗口的偏移没有被计算进去。解决方式是使用 renderer.domElement.getBoundingClientRect() 获取画布的实际边界,然后再做坐标换算。
二是相机的宽高比。当窗口 resize 时,一定要同步更新相机的 aspect 和渲染器的尺寸:
window.addEventListener('resize', () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); });updateProjectionMatrix() 很多人会漏,漏掉之后画面会变形,交互坐标自然也对不上。我每次写 resize 都条件反射般把它带上,这已经是肌肉记忆了。
4.4 性能与掉帧优化清单
如果场景已经跑起来了但帧率很低,可以按这个清单逐项排查:
- 减少 draw call,优先合并几何体,把相同材质的 Mesh 丢进一个 Group 再合并。
- 启用 renderer.shadowMap.enabled 时要注意阴影贴图的分辨率,过大会严重影响性能,一般 2048 就够。
- 控制纹理尺寸,大纹理用 texture.generateMipmaps 配合 anisotropy 设置,不要每个材质都加载 4K 贴图。
- 使用 Level of Detail 或者多层加载策略,远处模型显示低模,近处显示高模。
- 清理不再使用的几何体、材质和纹理,手动调用 dispose() 释放 GPU 内存。
我在 t3code 的性能测试里发现,一个简单的场景,如果所有物体都是独立 Mesh,不加任何优化,100 个物体大约 100 次 draw call,在低端手机上已经开始掉帧。合并之后同样数量降到 10 次以内,帧率能稳定在 60fps。所以优化不是可选项,而是 3D 项目上线前的必要环节。
最后再分享一个小技巧: 调试时在浏览器左上角打开 FPS 面板,配合 Three.js 的 renderer.info,能直观看到每次渲染调用了多少次 draw call、有多少个 Triangle。这个数据会告诉你瓶颈在哪。我每次调优都是先看 renderer.info.render.calls,如果这个数字降不下来,其他优化都是白费。
t3code 走到现在,最大的体会就是 3D 开发不要被复杂的原理吓倒,先跑通一个最小闭环,再往里面加效果。遇到问题先把场景里的角色拆开排查,渲染器、相机、灯光、模型、交互一层一层看,基本能解决九成的问题。后续我还会往 t3code 里加粒子系统、后处理和物理碰撞的示例,如果你也做了类似的东西,欢迎交流。