第一次看到 t3code 这个项目代号时,我第一反应是某个工具链的缩写,后来翻完代码才确认,这是一个基于 TypeScript 和 Three.js 的 Web 3D 场景示例仓库。项目的诉求很纯粹:把前端工程师从"能跑 demo"带到"能写自己的三维交互应用"。标题里没有花哨的营销词,但代码结构本身已经把答案写得很清楚——用强类型管住 Three.js 里最容易失控的复杂状态,用工程化的方式组织场景、相机、灯光和交互逻辑。
这个内容适合谁?一类是已经写过原生 JavaScript + Three.js、但被回调地狱和全局变量折磨过的开发者;另一类是刚接触 Web 3D、想直接看一套"不是玩具级"的项目骨架的人。它能解决的核心问题也很明确:Three.js 本身不强制你用什么架构,但项目一复杂,场景对象、动画循环、资源释放、类型安全全都会变成坑。t3code 提供的是一个经过取舍的参考模板,告诉你哪些地方该抽象、哪些地方该直接写,以及为什么这么分。
1. 项目整体设计与技术选型思考
1.1 为什么是 TypeScript 而不是纯 JavaScript
Three.js 的 API 体量很大,光几何体、材质、光照就各有几十个类,更别提后期处理、加载器、动画系统这些模块。用纯 JavaScript 写,最大的痛苦不是语法,而是你根本记不住每个对象的属性结构。今天忘了一个MeshStandardMaterial需要roughness还是metalness,明天忘了PerspectiveCamera构造参数的顺序,等编译期报错比运行时黑屏强得多。
TypeScript 给 Three.js 项目带来的第一个好处就是属性提示。你在编辑器里打出mesh.material.,候选列表直接列出roughness、metalness、map、normalScale,不用来回翻文档。第二个好处是重构安全。三维场景里对象之间的引用关系特别复杂,比如一个模型组里嵌套了网格、灯光、动画控制器,如果你想调整树形结构,纯 JavaScript 删一个字段可能引发连锁运行时错误,TypeScript 会在保存文件的那一秒告诉你哪里断了。
但选型不是没有代价。TypeScript 的编译配置对 Three.js 有要求,特别是moduleResolution和types字段,稍不注意就会出现类型声明找不到的问题。t3code 里用的是一套比较稳妥的组合:"module": "ESNext"、"moduleResolution": "Bundler",搭配 Vite 作为开发服务器和打包器。Three.js 从 r150 开始就把类型定义内置在包里,不需要额外的@types/three,这是个大好消息,少踩一个依赖版本错位的坑。
1.2 项目结构:模块边界怎么画
我先放一张 t3code 的目录结构,后面所有讲解都围绕这个展开:
t3code/ ├── src/ │ ├── core/ │ │ ├── Engine.ts // 渲染器、场景、相机的创建与绑定 │ │ ├── Loop.ts // requestAnimationFrame 循环与时钟管理 │ │ └── Resizer.ts // 窗口尺寸变化时自动更新相机比例 │ ├── scenes/ │ │ ├── BaseScene.ts // 抽象基类,定义场景的通用生命周期 │ │ └── DemoScene.ts // 实际场景:灯光、模型、交互逻辑 │ ├── components/ │ │ ├── CameraController.ts // 围绕鼠标控制的轨道相机封装 │ │ └── Environment.ts // 环境贴图与基础光照预设 │ ├── utils/ │ │ ├── assetLoader.ts // 模型与纹理资源的统一加载 │ │ └── perfMonitor.ts // 帧率统计与性能标记 │ ├── styles/ │ │ └── main.css │ ├── main.ts │ └── vite-env.d.ts ├── public/ │ └── assets/ │ ├── models/ // glTF / GLB 模型文件 │ └── textures/ // 纹理、HDR 环境贴图 ├── index.html ├── package.json ├── tsconfig.json └── vite.config.ts这个结构背后有一个核心设计思路:把"引擎能力"和"场景业务"分开。core目录只负责 WebGL 渲染器、场景容器、相机、动画循环这些跟业务无关的底层能力;scenes目录才放具体要渲染什么内容、交互逻辑怎么走。这样拆的好处是,你换一个场景不用动引擎代码,加一个场景也不用复制粘贴渲染器初始化。
Engine.ts是全局单例,它持有WebGLRenderer、Scene和Camera的引用。这个设计可能有人会觉得"单例不好",但放在 Web 3D 项目里,绝大多数页面一个渲染器就够了,单例能避免多个渲染器抢占 WebGL 上下文的问题。浏览器对 WebGL 上下文数量有限制,一般是 8 到 16 个,你不节制地 new 渲染器,早晚把上下文耗光。
BaseScene.ts则定义了一套生命周期钩子:init()、update(time)、resize()、dispose()。所有具体场景继承这个基类,强制实现这些方法。这看起来简单,但实际项目中非常有用——很多新手写的 Three.js 代码根本没有销毁的概念,路由跳转之后渲染器还在跑,GPU 资源一直占着,最后整个页面越来越卡。有生命周期约束,至少你在切换场景时知道该清理什么。
2. 核心模块拆解与实操要点
2.1 渲染器与相机配置:参数不是随便填的
Engine.ts里有几行关键配置,每个参数我都要解释一遍为什么这么设,因为网上大量教程要么手抖填错,要么根本没说清楚。
// core/Engine.ts const renderer = new THREE.WebGLRenderer({ antialias: true, // 开启 MSAA 抗锯齿 alpha: true, // 背景透明,方便叠加页面元素 powerPreference: "high-performance", }); renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.0; renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap;antialias这个参数,很多 demo 都开了,但很少有人提它的代价。它本质上是让 GPU 做多重采样,像素填充率直接翻倍,如果你做的是移动端或者高分辨率场景,帧率会明显下降。t3code 用PCFSoftShadowMap做阴影过滤,这比默认的 basic 阴影柔和很多,代价是阴影贴图的采样次数增加,需要根据性能情况取舍。
相机参数也值得单独说。Three.js 的PerspectiveCamera构造函数是(fov, aspect, near, far),这四个参数每改一个都影响画面表现。
const camera = new THREE.PerspectiveCamera( 45, // 视野角度 window.innerWidth / window.innerHeight, // 宽高比 0.1, // 近裁剪面 1000 // 远裁剪面 );fov选 45 度是工程上很常用的平衡点。视野太窄(比如 20 度),物体看起来像被长焦镜头盯着,空间感被压缩;视野太宽(比如 90 度),边缘形变严重,像鱼眼镜头。near和far才是真正的陷阱。near设置太大会导致近处的物体被裁掉,设置太小(比如 0.0001)又容易引发深度冲突——两个靠得很近的面会不停闪烁。far设置过大也会有问题,它会压缩深度缓冲的精度,远处物体产生 z-fighting。t3code 里默认场景不大,相机和物体的距离在 2 到 50 之间,所以near = 0.1、far = 1000是足够用的,如果你的场景有巨大开阔地形,得重新算这两端的比值。
2.2 灯光与材质:为什么画面总是不"通透"
很多人用 Three.js 做完第一版场景,都会遇到同一个问题:模型加载出来了,但颜色发灰、阴影脏,看起来像有一层雾。这个问题的根源绝大部分不在模型,而在灯光组合和色彩空间设置上。
t3code 的Environment.ts里给了一套标准组合:一个方向光作为主光源,一个环境光做补光,再加一个 HDR 环境贴图模拟全局光照。方向光负责产生清晰的阴影和立体感,环境光负责把暗部抬起来,HDR 贴图让金属和玻璃材质有正确的高光反射。
// components/Environment.ts const directionalLight = new THREE.DirectionalLight(0xffeedd, 3); directionalLight.position.set(5, 8, 6); directionalLight.castShadow = true;颜色选0xffeedd而不是纯白0xffffff很多人会忽略。真实世界的光是有色温的,暖色光(偏橙)在实际渲染里更容易出效果,让物体的受光面和暗面产生冷暖对比,画面层次一下子就有了。纯白环境光的结果就是"医院走廊灯"效果,平、冷、生硬。
材质这块,t3code 里主要用MeshStandardMaterial或者MeshPhysicalMaterial。这两个材质都是基于物理渲染(PBR)的,核心参数是roughness和metalness。金属度 0 是非金属,1 是纯金属;粗糙度 0 是镜面,1 是磨砂。一个常见的误区是新手会把粗糙度调到 0 想做出"闪亮"的效果,结果整个表面变成一面镜子,什么都看不清。正确的做法是:金属物体粗糙度 0.1 到 0.3,非金属物体粗糙度 0.4 到 0.8,高光靠环境贴图,而不是靠把粗糙度压到极端。
2.3 动画循环与时钟:别再用Date.now()算时间
动画循环是 Web 3D 项目的引擎,t3code 的Loop.ts里没有用setInterval,也没在requestAnimationFrame的回调里直接拿时间戳,而是用了 Three.js 的Clock。
// core/Loop.ts import { Clock } from "three"; const clock = new Clock(); function tick() { const delta = clock.getDelta(); const elapsed = clock.getElapsedTime(); // 更新场景 currentScene?.update(delta, elapsed); renderer.render(scene, camera); requestAnimationFrame(tick); }为什么必须用 delta(帧间隔)而不是绝对时间?因为不同显示器的刷新率不一样。60Hz 的屏幕一帧是 16.7 毫秒,120Hz 的屏幕一帧是 8.3 毫秒。如果你在动画函数里写position.x += 0.01,120Hz 屏幕上物体移动速度是 60Hz 屏幕的两倍,感觉就像开了加速。用delta乘以速度,才能保证任何刷新率下运动速度一致。
clock.getDelta()有个特别需要注意的坑:它每次调用都会重置内部状态,所以你一帧内只能调用一次。如果你在tick()里先给网格 A 算了delta,后面给网格 B 又调用一次,得到的是几乎为 0 的数值,动画会卡死。正确做法是在tick()的最开头取一次delta,然后传给所有需要更新动画的对象。
// 错误写法 cubeA.rotation.x += clock.getDelta() * 2; cubeB.rotation.y += clock.getDelta() * 2; // 这里 delta 接近 0 // 正确写法 const delta = clock.getDelta(); cubeA.rotation.x += delta * 2; cubeB.rotation.y += delta * 2;这个细节,第一次踩坑的时候排查了半个小时才反应过来,写在这里能帮你省半小时。
2.4 轨道控制器与交互体验
t3code 的交互部分用的是OrbitControls,这是 Three.js 官方提供的最常用的相机控制器。它让用户可以通过鼠标拖拽旋转视角、滚轮缩放、右键平移,基本覆盖了三维展示的大部分需求。
// components/CameraController.ts import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; // 开启惯性阻尼 controls.dampingFactor = 0.08; // 阻尼系数 controls.minDistance = 3; controls.maxDistance = 30; controls.target.set(0, 1, 0);enableDamping为什么要点开?不开的话,鼠标拖动结束后相机立刻停住,手感生硬;开了之后,相机有很小的惯性,会继续朝运动方向滑一小段再停下,整体感觉丝滑很多。但要记住:开了阻尼,就必须在动画循环里调用controls.update(),否则相机永远朝目标位置不紧不慢地插值,画面会一直"飘"。
minDistance和maxDistance是限制相机缩放范围用的,不设的情况下用户把相机怼到模型内部,穿模之后就再也转不出来了。限制值一定要根据场景尺寸来定,t3code 里的模型身高约 2 米,所以minDistance = 3、maxDistance = 30比较合适。如果你的场景是一个城市模型,这些值显然就不适用了。
另外一个容易忽略的细节是controls.dispose()。OrbitControls 绑定在renderer.domElement上,它内部注册了大量的鼠标和触摸事件监听。如果路由切换后你没有调用 dispose,旧的事件监听还挂在 canvas 上,新页面又创建一个新的 controls,会同时触发两套逻辑,出现缩放失效、旋转抖动的怪异行为。这个坑在单页应用里尤其常见。
3. 从零跑通 t3code 的完整流程
3.1 环境准备与依赖安装
我假设你已经装了 Node.js 18 以上版本和 npm。t3code 使用 Vite 作为构建工具,最重要的原因是它的依赖预构建和热更新对 Three.js 这种大型库支持得非常好。你第一次启动开发服务器,Vite 会把 Three.js 预打包成 esbuild 格式的依赖,页面加载速度比 webpack 那种逐模块编译快一个数量级。
克隆或者创建完项目之后,安装依赖:
npm install依赖的package.json核心部分长这样:
{ "dependencies": { "three": "^0.160.0" }, "devDependencies": { "@types/three": "^0.160.0", "typescript": "^5.4.0", "vite": "^5.2.0" } }注意three和@types/three的版本。从 r150 之后 Three.js 官方类型声明直接内置,但为了兼容一些编辑器插件,很多项目仍然会装@types/three,这个包实际上是自动从 Three.js 仓库同步的,版本号保持一致就不会出问题。最怕的是 two.js 用的 r160,types 用的 r150,API 不一样,编译直接裂开。
启动开发服务器:
npm run dev如果一切正常,终端会输出一个本地地址,打开之后就能看到场景了。这里有一个小技巧:开发期间一定要打开浏览器的 WebGL 报错提醒,Chrome 的 DevTools 里勾选 "WebGL" 日志级别,很多着色器编译错误和纹理加载问题会直接显示在控制台,省去瞎猜的时间。
3.2 实现第一个自定义场景
t3code 里更换场景的流程很直接。继承BaseScene,实现生命周期方法,然后在main.ts里切换到新的场景类。
// scenes/MyScene.ts import * as THREE from "three"; import { BaseScene } from "./BaseScene"; export class MyScene extends BaseScene { private cube!: THREE.Mesh; init(): void { // 添加地面 const plane = new THREE.Mesh( new THREE.PlaneGeometry(20, 20), new THREE.MeshStandardMaterial({ color: 0xcccccc, roughness: 0.8 }) ); plane.rotation.x = -Math.PI / 2; plane.receiveShadow = true; this.add(plane); // 添加旋转立方体 const geometry = new THREE.BoxGeometry(1, 1, 1); const material = new THREE.MeshStandardMaterial({ color: 0x4a90d9, roughness: 0.4, metalness: 0.2, }); this.cube = new THREE.Mesh(geometry, material); this.cube.position.set(0, 0.5, 0); this.cube.castShadow = true; this.add(this.cube); } update(delta: number): void { this.cube.rotation.x += delta * 0.5; this.cube.rotation.y += delta * 0.8; } dispose(): void { // 清理几何体和材质 this.cube.geometry.dispose(); (this.cube.material as THREE.Material).dispose(); this.clear(); } }这里有个关键点:dispose()方法里不只清空场景,还显式调用了geometry.dispose()和material.dispose()。Three.js 里的几何体和材质数据都存在 GPU 缓冲区里,如果你只是从场景中移除对象,GPU 端的内存不会自动释放。开发时不注意还好,频繁创建销毁对象后,显存会缓慢涨上去,最后页面白屏。这条经验在单页应用里特别重要。
3.3 资源加载:glTF 模型与纹理
如果只是展示盒子,那场景太单薄了。真实项目里至少会加载一个 glTF/GLB 格式的模型。t3code 的assetLoader.ts封装了一套标准流程:
// utils/assetLoader.ts import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js"; const gltfLoader = new GLTFLoader(); const dracoLoader = new DRACOLoader(); dracoLoader.setDecoderPath("https://www.gstatic.com/draco/versioned/decoders/1.5.6/"); gltfLoader.setDRACOLoader(dracoLoader); export async function loadModel(url: string): Promise<THREE.Group> { const gltf = await gltfLoader.loadAsync(url); return gltf.scene; }DRACO 解码器的路径很关键。很多模型文件是经过 Draco 压缩的,加载时必须指定解码器脚本的地址。可以看到这里用的是 Google 托管的公共 CDN,因为 Draco 解码器是编译好的 JS 文件,不需要你本地维护。但生产环境最好把解码器文件下载到自己的public/draco/目录下,避免 CDN 波动导致模型加载失败。
GLTFLoader 加载的是promise,所以可以用async/await写逻辑,代码清爽很多。加载完成后需要设置gltf.scene.traverse,对每个 Mesh 节点统一设阴影属性:
model.traverse((child) => { if ((child as THREE.Mesh).isMesh) { child.castShadow = true; child.receiveShadow = true; } });traverse是三维场景编辑里最高频的操作之一,它能递归遍历模型树里的所有节点,统一处理材质、阴影、碰撞体属性。如果模型导入之后没有投影,90% 的原因是忘了这一步。
4. 常见问题与排查技巧实录
4.1 问题速查表
这节内容全部来自实际跑 t3code 项目时真真切切踩过的坑,整理成速查表,遇到了直接对号入座。
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 页面白屏,控制台报 "WebGL context lost" | 浏览器 WebGL 上下文被耗尽或 GPU 进程崩溃 | 重启浏览器,检查是否多个页面都开了大量 Three.js 实例 |
| 模型加载后是黑的,没有颜色 | 环境贴图缺失,或者材质没有设置envMap | 检查Environment.ts里 HDR 贴图是否加载成功 |
| 模型加载缓慢,甚至超时 | 拖动文件体积大,缺少 Draco 压缩 | 用 glTF 格式并开启 Draco 压缩,或减少贴图尺寸 |
| 阴影忽明忽暗、闪烁 | 阴影贴图分辨率不足或相机 far 过大 | 调整shadowMapSize为 2048 或 4096 |
| 旋转模型时,背景也跟着动 | 背景贴图不在场景里,而是在相机上 | 检查scene.background是否被错误地赋值给相机background |
| 帧率低,GPU 占用 100% | 像素密度比未处理,高 DPI 屏幕渲染量翻倍 | 设置renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) |
| 相机穿墙、能转到模型内部 | OrbitControls 没有设置maxDistance或碰撞 | 暂时用 min/max 限制距离,或自行实现碰撞检测 |
| 转场后动画还在跑,页面卡顿 | 路由切换时没调用dispose() | 在组件卸载或路由守卫里调用场景的dispose() |
4.2 排查思路:从现象到根因
挑几个值得详细展开的问题说一下。
第一个是"像素密度比"导致的高帧率飙升。默认情况下,renderer.setPixelRatio(1)表示每 1 个 CSS 像素渲染 1 个物理像素。如果你的显示器是 Retina 级别,devicePixelRatio可能是 2 或 3,一个 CSS 像素被渲染成 2x2 甚至 3x3 个物理像素,渲染量变成原来的 4 倍或 9 倍。很多教程根本没提这回事,你代码逻辑没问题,帧率照样上不去。t3code 里统一做了上限 2 的处理,这个值在大多数设备上是画质和性能的平衡点。想更激进可以设成 1.5,画面会稍微柔和一点,但帧率提升明显。
第二个是阴影问题。shadowMap.enabled = true开了,但阴影还是硬邦邦的,且边缘锯齿严重,这是阴影贴图分辨率太低。Three.js 里默认的阴影像素是 512x512,你把它提升到 2048 或 4096,阴影质量立刻上一个档次。代价是 GPU 多付出 4 倍到 8 倍的填充率,所以只对近距离的主光源开高分辨率就够了,远处的小补光灯可以不开阴影,或者用低分辨率。
第三个是环境贴图加载失败导致全场景变暗。t3code 的Environment.ts会加载一个 HDR 环境贴图,如果因为网络问题加载失败,整个场景的间接光就消失了,所有物体看起来都是"面片感"——正面亮、背面黑。判断是否是这个问题的方法是:控制台看有没有加载失败的警告,或者把renderer.scene.background临时设成灰色,如果画面变亮一些,说明就是环境贴图缺失。
4.3 性能优化的几条硬经验
当你的 t3code 项目从简单展示走向复杂场景时,性能会成为绕不开的问题。给你几条我实测有效的建议。
第一,场景里可见的物体数量少,但每个物体多边形面数很高,依然会卡。这时候与其减少物体数量,不如用 LOD(细节层次)——远处用低模,近处用高模。Three.js 内置了THREE.LOD对象,实现起来很直接:同一个位置挂 2 到 3 个不同精度的网格,根据相机距离切换到合适的层级。
第二,纹理尺寸不是越大越好。一张 2K 贴图如果只需要看 1 米远的物体,直接压成 512 或 1024 完全够用。大贴图不仅占用显存,还拖慢加载速度。t3code 的加载器里默认对贴图做texture.colorSpace = THREE.SRGBColorSpace的设置,确保颜色还原准确。
第三,画面卡顿时先看任务管理器,确认是 GPU 满载还是 CPU 满载。GPU 满载说明渲染管线的负担重,优先考虑优化几何体数量或降低像素比;CPU 满载说明跑在前端逻辑,比如每帧都new了对象或做了大量垃圾回收,优先优化代码结构,比如把对象初始化提到构造函数里。
5. 项目体验与扩展建议
t3code 跑通之后,我发现它最大的价值不是那个 demo 本身,而是它提供了一个可以往上叠加功能的脚手架。基于它做二次开发,提几个我觉得性价比最高的方向供你参考。
如果想加后期特效,可以在Engine.ts里接配套的EffectComposer,用 bloom(泛光)或者 vignette(暗角)快速提升画面质感。这个流程在网上有很多参考,但要注意性能开销,bloom 在移动端特别吃 GPU,我建议只在桌面端启用。
如果想加交互,可以考虑接入简单的射线检测Raycaster,实现鼠标点击拾取物体、显示信息面板这类功能。Raycaster 的原理是发射一条从相机穿过鼠标位置的射线,和场景里每个物体的包围盒求交,能拿到距离最近的物体信息。加上之后,场景就不再是"只能看"的展示品,而是一个可以操作的工具。
如果你想做产品级项目,下一步必须补一套资源管理机制:加载进度条、错误重试、模型缓存。t3code 里资源加载是异步的,但缺少进度回调和统一状态管理,这些在生产环境是必备的,可以照着assetLoader.ts扩展。
最后分享一个我个人的小习惯:每次改动core目录下的代码,都顺手在浏览器控制台跑一遍renderer.info.render.calls,看绘制调用次数的变化。这个数字是一切性能问题的第一信号,如果场景没太大变化但次数翻倍,基本可以断定哪儿多了重复的网格或材质。靠这个习惯,我至少提前发现了三个隐蔽的性能回归问题。t3code 这个骨架,替你省掉了前期最繁琐的搭建,剩下的想象力,都交给你自己填了。