t3code 是我近期一直在维护的一个前端方向的小项目。先说结论:它不是什么惊天动地的框架,而是一个把 Three.js 场景搭建和代码生成打通的轻量工具集。名字里的 t3 代表 Three.js,code 强调最终产出的是可复用、可二次修改的代码。你可以在浏览器里调整场景、物体、灯光和交互参数,然后一键导出结构清晰的 TypeScript / JavaScript 项目,直接扔进自己的工程里运行。这篇文章我会把这个项目的设计思路、核心模块、完整实现流程和踩过的坑整理出来,适合正在学习 Three.js 的前端开发者,也适合想给团队做内部提效工具的朋友参考。
1. 整体设计与思路拆解
1.1 为什么不是又一个“threejs 在线编辑器”
最初有这个想法,是因为我发现自己每隔一两周就会写一遍几乎相同的 Three.js 初始化代码:创建场景、设置相机、添加轨道控制器、加载模型、调灯光。这个东西本身不复杂,但零散地分布在每一个项目里,每次都要重新回忆参数、检查兼容性。市面上的在线 3D 编辑器也有不少,但它们大多面向作品展示,导出的内容往往是一整个项目包,里面有大量我不需要的依赖和样式,接入现有代码时反而要花时间做减法。
t3code 的定位完全不同。它希望把“参数配置—代码预览—代码导出”变成一条短链路,所有操作的结果都是围绕代码展开的。你在页面上拖一个立方体、调一下材质颜色,右侧的代码面板会同步生成对应的 Object3D 代码;你改一下相机位置,代码里的 perspectiveCamera 参数也会跟着更新。这种“所见即所得”的方式,对于熟悉 Three.js 但不熟悉繁琐 API 的人来说,能省掉大量试错时间。同时,导出的代码是模块化的,不是一整块无法维护的单文件,而是按照 scene / objects / components / utils 分开的,方便直接 diff、review 和迭代。
1.2 技术选型背后的权衡
做这种工具,技术栈的选择很大程度上决定了项目的上限。
- 核心渲染引擎:毫无疑问用 Three.js。它的生态最成熟,社区例子多,遇到问题很容易搜到解决方案。虽然也有 react-three-fiber 或者 Babylon.js 这类选择,但前者在生成“纯前端可读代码”这件事上并不直接,后者在浏览器端打基础包和插件体系上更重。t3code 的核心价值是“生成标准 Three.js 代码”,所以引擎直接用 Three.js 最透明。
- 状态管理:没有用 Redux 或 Zustand,而是直接用 React 的 useState + useReducer,配合一个简单的 EventEmitter 来做跨组件同步。原因很简单,这个工具的核心状态是“场景树 JSON”和“代码预览字符串”,它们之间是单向依赖:状态变化触发代码生成,代码生成结果反馈到预览面板。不需要复杂的数据流,引入重型状态管理反而让代码更绕。
- 代码编辑器:预览面板用的是 CodeMirror 6。它比 Monaco 轻不少,对长文本的高亮和折叠也够用。Monaco 适合做完整 IDE,但在这个场景下会拖慢首屏加载,体验反而不如 CodeMirror 流畅。
- 构建工具:Vite。开发环境启动快、热更新响应及时,构建产物用 Rollup 打包也很方便。t3code 本身是一个工具型站点,不是大型应用,Vite 是最平衡的选择。
这套组合的核心逻辑是:能内建的就内建,能不引入重型依赖就不引入。工具类项目最重要的不是技术栈华丽,而是每天打开它、用它、改它都不烦躁。
2. 核心模块细节与实操要点
2.1 场景配置模块:参数与代码的双向同步
场景配置是这个工具的第一层。在 t3code 里,场景相关的所有核心参数都集中在左侧面板中,包括背景颜色、环境光强度、阴影开关、雾效参数等。界面上的每个输入框、开关、颜色选择器都对应一份 JSON 配置中的某个字段,这个 JSON 就是场景的“单一数据源”。
具体实现上,我维护了一个 sceneConfig 对象,例如:
{ renderer: { antialias: true, alpha: true, shadowMap: { enabled: true, type: 'PCFSoftShadowMap' }, outputColorSpace: 'SRGBColorSpace', toneMapping: 'ACESFilmicToneMapping' }, scene: { background: '#1a1a2e', fog: { enabled: false, color: '#000000', near: 10, far: 100 } }, camera: { fov: 45, near: 0.1, far: 1000, position: [5, 5, 10] } }每次修改面板上的参数,就会执行一个 generateSceneCode 函数,把这个 JSON 逐一映射成 Three.js 的初始化代码。这里有一个细节值得提:渲染器的设置并不好从 Three.js 实例上直接反推。比如 toneMapping 是一个枚举值,但代码生成时不能只输出一个数值,而要映射成 THREE.ACESFilmicToneMapping 这种可读性强的常量。所以我在 JSON 配置里存的不是 Three.js 的枚举对象,而是一个字符串 key,由生成层统一做映射,这样既保证了配置的可序列化,也保证了生成代码的准确性。
实操时最需要注意的是类型的区分。相机 position 是一个数组,生成代码时要展开成具体的三个参数;布尔值 shadowMap.enabled 要生成 shadowMap: { enabled: true } 对象结构;颜色值要格式化为 '0x1a1a2e' 还是 '#1a1a2e',得根据 Three.js 版本的 API 来选择。这些都是很容易出错的小地方,我的做法是编写一套完整的快照测试,把三种典型场景配置跑一遍,比对生成的代码字符串是否和预期一致。
2.2 几何体与材质编辑器:避免无止境地堆参数
做几何体配置器的第一版时,我踩了一个大坑:我把 Three.js 每个几何体构造函数的参数全部放到了界面上,结果页面上的输入框有几十个,用户根本不知道怎么填。后来我调整了思路——只暴露每个几何体最核心的 2 到 4 个参数,其他参数都给一个合理的默认值,并在代码预览面板中完整显示。这样既不会让工具失去灵活性,也不会让界面变得不可用。
以 BoxGeometry 为例,面板上只展示 width、height、depth 和 segments,其中 segments 还默认折叠在高级选项里。SphereGeometry 则展示 radius、widthSegments、heightSegments。材质部分,我提供 baseColor / metalness / roughness / emissive 这几个 PBR 参数,这足以覆盖大多数普通使用场景。当用户在面板里修改一个几何体的尺寸时,右侧的代码预览会实时更新对应的几何体构造函数代码,同时 3D 视口立刻重新渲染。三个区域以配置 JSON 为数据源联动,形成闭环。
材质这块有个容易踩的细节:Metalness 和 Roughness 并不是相互独立的物理属性,粗糙度很低而金属度很高的材质在光照下会呈现类似镜面的反光,容易出现“死白”或曝光过度的观感。所以在材质编辑器中,我会在 roughness 调到低于 0.2 时给出提示,建议同时降低金属度或增强环境光。这个提示看似简单,但对刚接触 PBR 材质的人来说非常友好。
2.3 交互控制与动画模块:把常用交互做成可开关的组件
3D 场景不是静态的展示,交互控制决定了用户体验。t3code 默认集成了 OrbitControls,并且在面板中提供目标点、启用阻尼、启用自动旋转等选项。为什么选择 OrbitControls 而不是自己写相机控制?因为它是 Three.js 官方维护的控件,支持鼠标拖拽、缩放、触摸板手势等大多数常用的交互方式,稳定性和兼容性都经过了大量项目验证。自己写一套成本很高,而且很难在所有浏览器上表现一致。
在实现交互控制模块时,我把 OrbitControls 的代码封装成一个独立的 initializeControls 函数,导出时作为 utils 的一部分输出,而不是把几十行控件初始化代码塞进主场景文件。这个设计是为了让生成的代码在结构上更清晰。同时,我把“自动旋转”和“阻尼”绑定到统一的 controlsConfig 中,当用户在界面上开启自动旋转时,生成的代码会在渲染循环里加上controls.update()并设置controls.autoRotate = true,而不是在初始化代码里写死。
动画方面,我内置了三种基本动画模板:旋转动画、呼吸缩放动画和沿路径移动动画。它们都以组件的形式写好,用户选择一个目标物体后点击“添加动画”,就会生成对应的动画函数。早期版本中,动画逻辑和物体逻辑写在了一起,导致代码很长且难以切分。后来我强制分离:每个动画是一个独立的函数或类,接收 object 和 config 作为参数,这样代码预览和导出结构都会清晰很多。
2.4 代码生成与导出:核心中的核心
代码生成器的设计决定了 t3code 是否真正可用。我把它分成三层:配置模型层、代码生成层和格式化层。
配置模型层(src/models)负责把界面状态转换为稳定的配置对象,所有字段都有默认值;代码生成层(src/generators)负责把配置对象转化为代码字符串,这一层只做字符串拼接和缩进处理,不掺杂业务逻辑;格式化层则使用 Prettier 的标准配置对生成的代码进行格式化,确保输出的代码风格符合大多数项目的习惯。
导出功能支持两种形式。一种是导出单个场景代码文件,直接嵌入到已有项目中;另一种是导出完整的项目压缩包,里面包含 index.html、main.js(或 main.ts)、package.json 和 README.md。为了让压缩包开箱即用,我特意把 package.json 里的依赖版本做成了精确版本号,而不是带 ^ 的模糊范围。这样做的原因是:Three.js 在 r150 之后有一些 API 变动,如果用户使用模糊版本安装,可能导致代码运行时报错。精确版本会降低这种风险。
代码生成细节方面,我总结了一个心得:不要试图把代码拼接成“最优雅”的样子,而应该拼接成“最好阅读”的样子。生成代码中缩进统一为两个空格,每个块之间空一行,变量命名按照 Three.js 官方示例的习惯——renderer、scene、camera、controls。这样用户即使不看文档,也能一眼明白每段代码的作用。
3. 从零实现 t3code 的完整流程
3.1 项目初始化和目录结构
t3code 本身是一个 React + TypeScript 项目,我建议用 Vite 模板起步:
npm create vite@latest t3code -- --template react-ts cd t3code npm install three @types/three目录结构上,我把代码按照功能边界划分,避免所有逻辑都堆在 App.tsx 里:
src/ ├── components/ # UI 组件,如参数面板、视口、代码编辑器 ├── generators/ # 代码生成器,每个模块一个文件 ├── hooks/ # 状态同步、视口生命周期 hook ├── models/ # 配置对象类型定义和默认值 ├── scenes/ # 示例场景预设 ├── store/ # 轻量状态管理 └── utils/ # 通用工具函数这个结构参考了我在公司内部做中后台工具的目录划分思路。它的优点是:当你需要新增一个“灯光编辑器”时,你很清楚去 components 里加一个面板、去 generators 里加一个灯光代码生成器、去 models 里加一个灯光配置类型,三点式改动可以并行进行。
3.2 让视口与代码预览联动
整个项目中最核心的逻辑就是状态变化如何驱动视口和代码同步更新。我的做法是:把视口(3D Canvas)做成一个不关心业务状态的组件,它只暴露一个增量的 API。
function useViewport() { const canvasRef = useRef<HTMLCanvasElement>(null); const engineRef = useRef<Engine>(); useEffect(() => { if (!canvasRef.current) return; const engine = new Engine(canvasRef.current); engineRef.current = engine; return () => engine.dispose(); }, []); const updateScene = useCallback((config: SceneConfig) => { engineRef.current?.applyConfig(config); }, []); return { canvasRef, updateScene }; }然后,当用户修改左侧面板上的参数时,主状态会变为新的 sceneConfig。为了防止 3D 视口被频繁重建,Engine 内部维护一个“增量更新”机制:如果只是改了材质颜色,则只执行 material.color.set;如果改了场景背景,则只执行 scene.background。只有当用户切换几何体类型、改变几何体数量这些结构性变化时,才重新构建场景图。这样做的性能收益非常明显:基本可以稳定在 60fps,不会出现拖动一个滑动条就卡顿半天的体验问题。
代码预览侧的联动更简单:每次 config 变化时,使用 generator 生成新的代码字符串,然后调用 CodeMirror 的 dispatch 进行增量 diff 更新,而不是全量替换。好处是当代码较长时,浏览器的编辑光标不会跳动,用户的滚动位置也能保留。
3.3 核心代码生成器的实现思路
一个最朴素的场景代码生成器看起来并不复杂,但真正的难点在于拼接的准确性。以 scene 初始化为例:
function generateSceneCode(config: SceneConfig): string { const lines: string[] = []; lines.push(`import * as THREE from 'three';`); lines.push(`import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';`); lines.push(''); const rendererLines = [ 'const renderer = new THREE.WebGLRenderer({ antialias: true });', `renderer.setSize(window.innerWidth, window.innerHeight);`, `renderer.shadowMap.enabled = ${config.renderer.shadowMap.enabled};`, `renderer.toneMapping = ${config.renderer.toneMapping};`, 'document.body.appendChild(renderer.domElement);' ]; lines.push(...rendererLines); lines.push(''); // camera 和 scene 代码类似,省略 return lines.map(l => l.replace(/^/, ' ')).join('\n'); }我之所以把生成器写成“拼行 + 缩进”的风格,是为了让代码可读性更高,也方便调试。如果你把整个生成过程变成一个超长的模板字符串,一旦生成的代码有语法错误,很难定位是哪一段拼接出了问题。而逐行拼接时,每一行都对应一个明确的逻辑分支,排查成本会低很多。
真正上线前一定要做的一件事是:拿生成器的输出代码放到一个临时的 CodeSandbox 项目里跑一遍。我在开发过程中遇到过一次生成代码中颜色值写错格式导致渲染出来的物体是黑色的问题,后来把生成器的每个类型分支都补齐了单测,才算彻底解决这类问题。
3.4 构建与发布:把工具变成一个可分发站点
t3code 不需要部署到服务器,它是一个纯前端应用,构建后可以部署到任意静态资源托管平台。我自己的做法是用 Vite 的 base 配置,让构建产物能放到子路径下:
// vite.config.ts export default defineConfig({ base: process.env.NODE_ENV === 'production' ? '/t3code/' : '/', build: { outDir: 'dist', sourcemap: false } });这样做的好处是,团队成员可以直接通过 GitHub Pages 或者内部静态服务器访问最新版本,不需要额外配置环境变量。另外,我在构建流程里加了一步tsc --noEmit,确保类型错误不会带进产物,这一步在多人协作时尤其重要。
发布之后还应该准备一个小型文档页面,不需要很复杂,只要写清楚“如何导入导出的代码、依赖版本是多少、常见问题有哪些”。很多工具类项目死于不会用,而非不好用。文档写得好,工具的接受度会高很多。
4. 常见问题与排查技巧实录
4.1 高频问题速查表
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 生成的代码运行后场景是黑色 | 灯光未配置或材质为纯黑 | 检查环境光和方向光的强度,默认不要用 0x000000 作为材质颜色 |
导出的代码引用three/examples/jsm路径报错 | 包管理器版本差异 | 将依赖固定为与生成器测试一致的精确版本 |
| OrbitControls 拖动无效 | 渲染循环中没有调用controls.update() | 在动画循环中加上controls.update() |
| 导入的 GLTF 模型显示不出来 | 未设置合适的相机远平面 | 相机 far 值从 1000 调到 5000 或更大 |
| 代码预览卡顿 | 每次配置变更都全量刷新编辑器 | 改用 CodeMirror 的dispatch做增量更新 |
| 导出项目跑不起来 | 缺少type: module或入口路径错误 | 在 package.json 中配置正确的入口,检查导入路径后缀.js |
这些问题是真实项目里出现过的,并不是预想的场景。第 3 条特别容易发生在刚上手 OrbitControls 时,因为新版 Three.js 中update()必须在动画循环里调用,否则控件只会初始化,不会监听鼠标事件后更新相机位置。
4.2 性能与渲染坑
t3code 里默认展示的场景模型数量不大,所以性能优化压力不大。但如果你在配置面板里不断添加大量几何体,或导入高精度的 GLTF 模型,就会发现明显掉帧。我在工具里做了三件事来缓解这个问题:
- 动态细分:当一个物体的面数超过一定阈值时,视口展示用材质降级版,只在最终代码预览中保留完整拓扑。这保证了编辑过程的流畅性。
- 设备像素比限制:WebGLRenderer 默认会按 window.devicePixelRatio 去渲染,4K 屏幕上很容易算力不足。我强制
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)),这是在清晰度和性能之间比较合理的折中。 - 帧率监控:视口右上角显示实时的 fps,当低于 30 时给出提示,让使用者明确知道是自己的场景太重了,而不是浏览器卡了。
这些优化虽然不直接影响生成代码的质量,但能让用户在使用 t3code 的过程中感受到响应速度。工具如果卡顿,用户很难有耐心去调好一个场景。
4.3 生成代码的语义化问题
还有一个很容易踩坑的细节是生成代码的可读性。早期生成器直接根据面板配置输出字面量,比如颜色输出new THREE.Color('#1a1a2e')。用户复制到项目里觉得没问题,但一旦想修改,就必须在整个文件中搜索颜色字符串。后来我改成了“变量提升式”生成:在文件顶部生成一个清晰的const COLORS = { background: '#1a1a2e' }对象,场景代码中引用这个变量。这样代码更可维护,也更像一个认真编写的项目而不是自动生成物。习惯这种风格后,你再回头看满屏魔法数字的生成代码,会觉得完全无法接受。
5. 应用场景与影响范围
5.1 典型使用场景
t3code 产生价值的地方主要有几类:
第一类是产品展示页面的开发。许多团队需要做一个带 3D 展示的落地页,需求一般是“模型自动旋转、可拖拽查看细节”。在过去,你可能要从零搭一个 Three.js 项目,再把模型加载、控件、灯光一套流程走一遍。用 t3code,你只需要在视口里放好模型、开自动旋转、导出项目,再把模型文件放进 assets 目录,20 分钟内就能跑通。
第二类是内部数据可视化大屏。数据大屏上经常需要 3D 地球、3D 柱状图、流动线等效果。这些效果的底层逻辑是简单的几何体 + 灯光 + 相机动画,但每次写都很费时间。t3code 提供的动画模板能直接生成基础代码,团队只需要替换业务数据绑定的部分即可。
第三类是教学与演示。我见过不少高校老师在教 Web 3D 开发时,用 t3code 快速生成示例代码,再在课堂上去改颜色、换形状,让学生直观看到参数变化对渲染结果的影响。这种场景下,工具的“即时反馈”特性比代码质量更重要,而 t3code 恰好两者兼顾。
5.2 对团队开发效率的改变
在团队内部推广 t3code 之后,最明显的变化不是“3D 页面开发时间缩短了多少”,而是“以前不敢碰 3D 的后端合作者也能上手改场景了”。因为所有参数都浮在界面上,不再需要记忆 Three.js 的 API,接需求的人只要会读中文标签,就能完成大部分场景调整。这大大减少了“前端写 3D、后端维护困难”的情况。
当然,工具也有明确的边界。它不擅长处理复杂的自定义 Shader、骨骼动画、物理引擎这类高级功能。遇到这种需求,t3code 生成的代码更适合作为一个起点,你仍然需要在此基础上继续深入修改。工具的价值是降低起步成本,而不是替代专业 3D 开发。
写在最后
t3code 这个项目给我最大的启发是:做工具类项目,不需要一味追求功能的堆叠,关键是想清楚你的用户在哪个环节最痛,然后把那个环节做到极其顺手。对我来说,最痛的就是“查 API、调参数、复制代码”这三件事之间的来回切换。t3code 把这几个环节收拢到了一起,虽然它永远不会替代手工编写复杂的 3D 应用,但对于快速搭建场景和验证想法,确实实打实地节省了时间。
如果你也在做类似的前端工具,我最后想分享的一条经验是:数据模型的设计一定要优先于界面设计。先把代码生成器跑通,然后再去做面板,会让你少做很多返工。毕竟代码生成器是这个工具的心脏,界面只是它的一层皮肤。