第一次看到 t3code 这个项目代号时我第一反应是某个工具链的缩写后来翻完代码才确认这是一个基于 TypeScript 和 Three.js 的 Web 3D 场景示例仓库。项目的诉求很纯粹把前端工程师从能跑 demo带到能写自己的三维交互应用。标题里没有花哨的营销词但代码结构本身已经把答案写得很清楚——用强类型管住 Three.js 里最容易失控的复杂状态用工程化的方式组织场景、相机、灯光和交互逻辑。这个内容适合谁一类是已经写过原生 JavaScript Three.js、但被回调地狱和全局变量折磨过的开发者另一类是刚接触 Web 3D、想直接看一套不是玩具级的项目骨架的人。它能解决的核心问题也很明确Three.js 本身不强制你用什么架构但项目一复杂场景对象、动画循环、资源释放、类型安全全都会变成坑。t3code 提供的是一个经过取舍的参考模板告诉你哪些地方该抽象、哪些地方该直接写以及为什么这么分。1. 项目整体设计与技术选型思考1.1 为什么是 TypeScript 而不是纯 JavaScriptThree.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.01120Hz 屏幕上物体移动速度是 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 用的 r160types 用的 r150API 不一样编译直接裂开。启动开发服务器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): PromiseTHREE.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 这个骨架替你省掉了前期最繁琐的搭建剩下的想象力都交给你自己填了。