1. 为什么我不建议你从零手写渲染循环
很多人第一次接触 three.js 编辑器开发,脑子里蹦出来的第一个念头是"我要自己写一套渲染管线"。这个想法本身没错,但如果你真的从requestAnimationFrame开始一行行搭,大概率会在第三天卡在"为什么拖拽物体时场景会闪烁"这种问题上。我见过太多类似的案例,包括我自己早期做的一个小工具,当时为了追求所谓的"完全可控",硬是用原生 WebGL 写了个简易场景管理器,结果光是处理相机矩阵和物体拾取的坐标转换就耗掉了整整一周。
three.js 的价值不在于它帮你省了多少行代码,而在于它把场景图(Scene Graph)、相机投影、光照模型、材质系统这些概念用一套统一的抽象封装好了。你要做的是一个编辑器,不是渲染引擎。编辑器的核心诉求是"让用户能直观地操作三维空间中的对象",而不是"证明我能手写矩阵运算"。
所以我的建议很明确:用 three.js 做渲染底座,把精力全部放在编辑器交互逻辑上。具体来说,你需要 three.js 帮你解决这几件事:
- 场景管理:
THREE.Scene作为根节点,所有物体挂上去就能被渲染 - 相机控制:
OrbitControls直接给你轨道旋转、平移、缩放 - 射线拾取:
Raycaster帮你把屏幕坐标转成三维空间中的物体引用 - 变换控制:
TransformControls提供拖拽手柄,省去自己写 gizmo 的功夫
这四个模块是编辑器的骨架。剩下的工作,比如属性面板、层级树、撤销重做、序列化保存,才是你真正要花时间打磨的地方。
注意:
TransformControls和OrbitControls在 three.js 的 examples 目录里,不是核心包的一部分。你需要从three/examples/jsm/controls/路径导入,别傻乎乎地去 npm 上找单独的包。
我见过有人为了"减少依赖"把这两个控件用原生鼠标事件重写了一遍,结果拖拽手柄的旋转轴判断逻辑写了八百行还没搞定。这不是勤奋,这是跟自己过不去。
2. 编辑器的最小可用骨架长什么样
在动手写代码之前,先把编辑器的功能边界划清楚。一个"简单编辑器"到底要支持什么?我的定义是:能加载模型、能选中物体、能变换物体、能修改材质、能保存场景。这五件事覆盖了 80% 的使用场景,剩下的高级功能可以后续迭代。
2.1 目录结构与模块划分
我习惯把编辑器拆成四个独立模块,每个模块只干一件事:
editor/ core/ SceneManager.js // 场景、相机、渲染器初始化 SelectionManager.js // 射线拾取与选中状态管理 TransformManager.js // 变换控制器封装 ui/ HierarchyPanel.js // 层级树面板 PropertyPanel.js // 属性编辑面板 Toolbar.js // 顶部工具栏 io/ SceneSerializer.js // 场景序列化与反序列化 main.js // 入口,组装所有模块这样拆的好处是,每个模块可以单独测试。比如你想验证射线拾取是否准确,只需要在SelectionManager里写个测试用例,不用启动整个编辑器。
2.2 初始化场景的三个关键参数
SceneManager的初始化看起来简单,但有几个参数如果设错了,后面会很难受:
import * as THREE from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; class SceneManager { constructor(container) { this.scene = new THREE.Scene(); this.scene.background = new THREE.Color(0x2a2a2a); const { clientWidth, clientHeight } = container; this.camera = new THREE.PerspectiveCamera( 50, // fov clientWidth / clientHeight, // aspect 0.1, // near 2000 // far ); this.camera.position.set(5, 5, 5); this.renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true // 截图功能需要 }); this.renderer.setSize(clientWidth, clientHeight); this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); this.renderer.shadowMap.enabled = true; this.renderer.shadowMap.type = THREE.PCFSoftShadowMap; container.appendChild(this.renderer.domElement); this.controls = new OrbitControls(this.camera, this.renderer.domElement); this.controls.enableDamping = true; this.controls.dampingFactor = 0.05; this.setupLights(); this.setupGrid(); } }这里有几个点值得展开说。
fov 选 50 而不是 75。75 是很多教程的默认值,视野大、看起来"开阔",但在编辑器场景里,75 的透视畸变太严重,拖拽物体时距离感会失真。50 更接近人眼观察习惯,操作精度更高。
pixelRatio 限制在 2 以内。现在很多手机和笔记本的 devicePixelRatio 是 3 甚至 4,如果不限制,渲染分辨率会飙升到 4K 以上,帧率直接掉到个位数。限制在 2 是画质和性能的平衡点。
preserveDrawingBuffer 设为 true。这个参数默认是 false,开启后会略微降低性能,但如果你要做"导出截图"功能,不开这个参数toDataURL()拿到的是一片空白。我当初排查这个问题花了两个小时,最后发现是浏览器在渲染完成后清空了缓冲区。
2.3 网格与光照的配置细节
网格线(GridHelper)和坐标轴(AxesHelper)是编辑器的"空间参照物",没有它们用户根本分不清东南西北:
setupGrid() { const grid = new THREE.GridHelper(20, 20, 0x444444, 0x333333); grid.material.opacity = 0.5; grid.material.transparent = true; this.scene.add(grid); const axes = new THREE.AxesHelper(3); this.scene.add(axes); } setupLights() { const ambient = new THREE.AmbientLight(0xffffff, 0.6); this.scene.add(ambient); const directional = new THREE.DirectionalLight(0xffffff, 1.2); directional.position.set(10, 20, 10); directional.castShadow = true; directional.shadow.mapSize.set(2048, 2048); directional.shadow.camera.left = -15; directional.shadow.camera.right = 15; directional.shadow.camera.top = 15; directional.shadow.camera.bottom = -15; this.scene.add(directional); }环境光给 0.6 是为了让背光面不至于全黑,方向光给 1.2 是为了让正面有足够的对比度。阴影相机的范围要手动设置,默认范围很小,物体一多阴影就会消失。
提示:阴影贴图设为 2048 是画质和显存的折中。如果你在低端设备上跑,可以降到 1024,肉眼几乎看不出差别,但显存占用少一半。
3. 射线拾取:编辑器交互的命门
编辑器的核心交互是"点击选中物体",这个功能的底层依赖是射线拾取(Raycasting)。原理不复杂:从相机位置发出一条穿过鼠标点击位置的射线,检测这条射线与场景中哪些物体相交,取最近的那个。
但实际写起来,坑比想象的多。
3.1 坐标转换的完整链路
鼠标事件的clientX/clientY是屏幕坐标,原点在左上角。而 three.js 的射线拾取需要的是归一化设备坐标(NDC),原点在屏幕中心,范围是 -1 到 1。转换公式如下:
const rect = renderer.domElement.getBoundingClientRect(); const ndcX = ((event.clientX - rect.left) / rect.width) * 2 - 1; const ndcY = -((event.clientY - rect.top) / rect.height) * 2 + 1;注意 Y 轴要取负,因为屏幕坐标 Y 向下,NDC 的 Y 向上。这个符号搞反了,点击位置会上下颠倒,排查起来很隐蔽。
然后设置射线:
const raycaster = new THREE.Raycaster(); raycaster.setFromCamera(new THREE.Vector2(ndcX, ndcY), camera); const intersects = raycaster.intersectObjects(scene.children, true);intersectObjects的第二个参数true表示递归检测子物体。如果你只传false,那么挂在 Group 下面的 Mesh 就选不中。
3.2 为什么你的点击总是选错物体
我遇到过三种典型的"选错"场景,每一种的原因都不一样:
第一种:选中了辅助对象。网格线、坐标轴、变换手柄这些辅助对象也会被射线检测到。解决方案是给它们打标记,拾取时过滤掉:
const intersects = raycaster .intersectObjects(scene.children, true) .filter(hit => !hit.object.userData.isHelper);第二种:选中了不可见物体。有些物体你设了visible = false,但射线检测依然会命中。需要在过滤时加判断:
.filter(hit => hit.object.visible)第三种:选中了背面。默认情况下,射线会穿透物体检测背面。如果你只想选中正面,需要设置材质的side属性为THREE.FrontSide,或者在拾取后检查法线方向。
3.3 选中高亮的实现方式
选中物体后要给它加个高亮效果,最直观的做法是加一个包围盒(BoxHelper):
selectObject(object) { if (this.currentHelper) { this.scene.remove(this.currentHelper); this.currentHelper.dispose(); } this.currentHelper = new THREE.BoxHelper(object, 0x00ff00); this.scene.add(this.currentHelper); this.selected = object; }BoxHelper会自动跟随物体的变换更新,不用手动计算包围盒。但要注意,切换选中物体时一定要dispose()掉旧的 helper,否则会内存泄漏。我见过一个项目跑了一下午后浏览器卡死,最后发现是每次点击都 new 一个 BoxHelper 但从不销毁。
另一种高亮方式是修改材质的emissive属性,但这种方式会污染原始材质,撤销选中时还得恢复,不如 BoxHelper 干净。
4. 变换控制器:拖拽手柄的封装与陷阱
TransformControls是 three.js 官方提供的变换手柄,支持平移、旋转、缩放三种模式。用起来很简单,但有几个坑必须提前知道。
4.1 模式切换与快捷键绑定
import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js'; this.transformControls = new TransformControls(this.camera, this.renderer.domElement); this.transformControls.addEventListener('dragging-changed', (event) => { this.orbitControls.enabled = !event.value; }); this.scene.add(this.transformControls); // 快捷键 window.addEventListener('keydown', (e) => { switch (e.key.toLowerCase()) { case 'w': this.transformControls.setMode('translate'); break; case 'e': this.transformControls.setMode('rotate'); break; case 'r': this.transformControls.setMode('scale'); break; } });dragging-changed事件是关键。拖拽手柄时如果不禁用 OrbitControls,相机会跟着一起转,操作体验极差。这个事件在拖拽开始时触发value = true,结束时value = false。
4.2 变换手柄的层级问题
TransformControls本身是一个Object3D,它需要被添加到场景中才能渲染。但如果你把它加到场景根节点,它会出现在层级树面板里,用户可能会误选中它。
我的做法是把它加到一个独立的helperScene里,或者给它打上userData.isHelper = true标记,在层级树渲染时过滤掉。
4.3 吸附与步进
编辑器里经常需要"精确移动",比如每次移动 0.5 个单位。TransformControls支持吸附设置:
this.transformControls.setTranslationSnap(0.5); this.transformControls.setRotationSnap(THREE.MathUtils.degToRad(15)); this.transformControls.setScaleSnap(0.1);按住 Shift 键时吸附生效,松开则自由变换。这个交互逻辑和主流三维软件一致,用户上手成本低。
注意:吸附值不要设得太小,比如 0.01,那样和自由变换没区别,用户感觉不到吸附的存在。0.5 或 1 是比较合适的平移步进。
5. 层级树与属性面板的联动
编辑器的左侧通常是层级树,右侧是属性面板。这两个面板的数据源都是场景图,但更新时机不同。
5.1 层级树的递归渲染
层级树本质上是场景图的树形展示。递归遍历scene.children,为每个节点生成一个列表项:
function buildTree(object, depth = 0) { if (object.userData.isHelper) return null; const node = { id: object.uuid, name: object.name || object.type, children: [] }; object.children.forEach(child => { const childNode = buildTree(child, depth + 1); if (childNode) node.children.push(childNode); }); return node; }每次场景变化时重建整棵树是最简单的做法,但性能差。更好的方式是监听scene的childAdded和childRemoved事件,做增量更新。不过对于"简单编辑器"来说,场景里通常不超过几百个物体,全量重建完全够用。
5.2 属性面板的双向绑定
属性面板要显示选中物体的位置、旋转、缩放、材质颜色等信息。难点在于双向绑定:面板改数值要同步到物体,物体被拖拽后数值也要刷新到面板。
我的做法是:
- 面板输入框的
change事件触发时,更新物体属性 - 在渲染循环里检查选中物体的变换是否变化,如果变化则刷新面板
// 渲染循环中 const pos = this.selected.position; if (!pos.equals(this.lastPosition)) { this.propertyPanel.updatePosition(pos); this.lastPosition.copy(pos); }用equals比较而不是直接比较引用,因为 three.js 的 Vector3 是可变对象,拖拽时引用不变但值变了。
5.3 材质编辑的实时预览
修改材质颜色时,如果每次都重建材质,性能开销很大。正确做法是直接修改现有材质的属性:
onColorChange(hex) { if (this.selected && this.selected.material) { this.selected.material.color.set(hex); this.selected.material.needsUpdate = true; } }needsUpdate在修改颜色时其实不需要设,但修改贴图、透明度等属性时必须设,否则 GPU 不会重新编译着色器。
6. 场景序列化:保存与加载的完整方案
编辑器的最终产出是一个场景文件,用户下次打开时能恢复之前的状态。three.js 提供了Object3D.toJSON()方法,但直接用它有几个问题。
6.1 toJSON 的局限性
toJSON()会序列化物体的几何体、材质、变换等信息,但不会保存:
- 自定义的
userData - 外部加载的贴图引用(只保存 URL)
- 编辑器的特定状态(比如当前选中的物体)
所以我的做法是在toJSON的基础上做一层包装:
serialize() { const sceneData = this.scene.toJSON(); return { version: '1.0', timestamp: Date.now(), scene: sceneData, editorState: { cameraPosition: this.camera.position.toArray(), cameraTarget: this.orbitControls.target.toArray() } }; }6.2 加载时的对象重建
ObjectLoader可以反序列化toJSON的结果,但要注意异步问题:
import { ObjectLoader } from 'three'; async load(data) { const loader = new ObjectLoader(); const scene = await loader.parseAsync(data.scene); // 清空当前场景 this.scene.clear(); this.scene.add(...scene.children); // 恢复相机 this.camera.position.fromArray(data.editorState.cameraPosition); this.orbitControls.target.fromArray(data.editorState.cameraTarget); }parseAsync是必须的,因为贴图加载是异步的。如果用同步的parse,贴图会丢失。
6.3 外部资源的路径处理
如果场景里引用了外部模型或贴图,序列化时保存的是相对路径。加载时如果路径变了,资源就找不到。解决方案是在保存时把资源转成 base64 内嵌,或者维护一个资源映射表。
对于简单编辑器,我建议直接内嵌 base64。虽然文件会变大,但胜在可靠。一个 1MB 的贴图转成 base64 后大约 1.3MB,对于场景文件来说可以接受。
7. 撤销重做:编辑器的灵魂功能
没有撤销重做的编辑器是不完整的。用户拖错一个物体,发现回不去,体验直接崩盘。
7.1 命令模式的设计
撤销重做的经典实现是命令模式。每个操作封装成一个命令对象,包含execute和undo两个方法:
class TransformCommand { constructor(object, oldPosition, newPosition) { this.object = object; this.oldPosition = oldPosition.clone(); this.newPosition = newPosition.clone(); } execute() { this.object.position.copy(this.newPosition); } undo() { this.object.position.copy(this.oldPosition); } }维护两个栈:undoStack和redoStack。执行新命令时压入undoStack并清空redoStack;撤销时从undoStack弹出,执行undo,压入redoStack。
7.2 拖拽操作的命令合并
拖拽物体时,TransformControls会连续触发objectChange事件。如果每次变化都生成一个命令,撤销一次只回退一帧,用户要按几百次才能回到起点。
正确做法是只在拖拽开始时记录初始状态,拖拽结束时生成一个命令:
this.transformControls.addEventListener('mouseDown', () => { this.dragStartPosition = this.selected.position.clone(); }); this.transformControls.addEventListener('mouseUp', () => { const command = new TransformCommand( this.selected, this.dragStartPosition, this.selected.position ); this.history.push(command); });这样一次拖拽对应一个撤销步骤,符合用户直觉。
7.3 历史栈的容量控制
历史栈不能无限增长,否则内存会爆。设一个上限,比如 50 步,超过时丢弃最早的命令:
push(command) { this.undoStack.push(command); if (this.undoStack.length > 50) { this.undoStack.shift(); } this.redoStack = []; }50 步对于大多数编辑场景够用了。用户如果连续操作超过 50 次还没保存,那说明工作流本身有问题。
8. 性能优化:让编辑器跑满 60 帧
编辑器卡顿是最劝退的体验。我总结了几条实战中验证有效的优化策略。
8.1 按需渲染
默认情况下,three.js 每帧都在渲染,哪怕场景没有任何变化。编辑器的场景通常是静态的,只有用户操作时才需要重绘。改成按需渲染:
let needsRender = true; function animate() { requestAnimationFrame(animate); orbitControls.update(); if (needsRender) { renderer.render(scene, camera); needsRender = false; } } // 任何会改变画面的操作后 function invalidate() { needsRender = true; }在OrbitControls的change事件、TransformControls的objectChange事件、属性面板修改后调用invalidate()。这样静止时 GPU 占用几乎为零,笔记本风扇不会狂转。
8.2 几何体合并
如果场景里有大量重复的小物体(比如一百个相同的立方体),每个物体单独渲染会产生一百次 draw call。用InstancedMesh合并:
const geometry = new THREE.BoxGeometry(1, 1, 1); const material = new THREE.MeshStandardMaterial({ color: 0x44aa88 }); const instancedMesh = new THREE.InstancedMesh(geometry, material, 100); const matrix = new THREE.Matrix4(); for (let i = 0; i < 100; i++) { matrix.setPosition(Math.random() * 10, 0, Math.random() * 10); instancedMesh.setMatrixAt(i, matrix); } scene.add(instancedMesh);但要注意,InstancedMesh的每个实例不能单独选中和变换,所以只适合做背景装饰,不适合做可编辑物体。
8.3 材质复用
每次new THREE.MeshStandardMaterial()都会创建一个新的着色器程序。如果一百个物体用一百个材质,GPU 要编译一百次。正确做法是复用材质:
const sharedMaterial = new THREE.MeshStandardMaterial({ color: 0xffffff }); for (let i = 0; i < 100; i++) { const mesh = new THREE.Mesh(geometry, sharedMaterial); scene.add(mesh); }但复用的代价是,修改一个物体的颜色会影响到所有共用该材质的物体。所以编辑器里通常是"默认共享,修改时克隆":
function onColorChange(object, color) { if (object.material.userData.shared) { object.material = object.material.clone(); object.material.userData.shared = false; } object.material.color.set(color); }8.4 阴影的代价
阴影是性能杀手。每盏投射阴影的光源都会触发一次额外的渲染 pass。如果场景里有多盏方向光,帧率会断崖式下跌。
我的建议是:编辑器里只保留一盏投射阴影的方向光,其他光源不投射阴影。视觉上几乎看不出差别,但性能提升明显。
9. 那些文档里不会写的踩坑记录
最后分享几个我在实际开发中踩过的坑,每一个都花了不少时间排查。
坑一:OrbitControls 的 target 被意外修改。当选中物体后,如果把orbitControls.target设为物体位置,相机会自动看向物体。但用户旋转视角后,target 会停留在物体上,导致后续操作视角很别扭。解决方案是只在双击物体时设置 target,单击选中不改变视角。
坑二:TransformControls 与射线拾取的冲突。拖拽手柄时,鼠标事件会被 TransformControls 捕获,但射线拾取依然在运行,导致拖拽过程中选中状态频繁切换。解决方案是在dragging-changed事件里禁用射线拾取。
坑三:窗口 resize 后相机宽高比没更新。这是最常见的问题,但很多人只更新了 renderer 的 size,忘了更新相机的 aspect:
window.addEventListener('resize', () => { const { clientWidth, clientHeight } = container; camera.aspect = clientWidth / clientHeight; camera.updateProjectionMatrix(); // 这行不能忘 renderer.setSize(clientWidth, clientHeight); });updateProjectionMatrix()不调用的话,相机的投影矩阵还是旧的,画面会被拉伸。
坑四:dispose 的遗漏。切换选中物体时,旧的 BoxHelper 要 dispose;删除物体时,几何体和材质要 dispose;关闭编辑器时,renderer 要 dispose。不 dispose 的话,显存会持续增长,最终导致页面崩溃。我写了一个disposeObject工具函数,递归清理所有资源:
function disposeObject(object) { if (object.geometry) object.geometry.dispose(); if (object.material) { const materials = Array.isArray(object.material) ? object.material : [object.material]; materials.forEach(m => { Object.values(m).forEach(v => { if (v && v.isTexture) v.dispose(); }); m.dispose(); }); } object.children.forEach(disposeObject); }这个函数在删除物体和清空场景时都要调用。
坑五:键盘快捷键与输入框冲突。用户正在属性面板的输入框里打字,按下 W 键想输入字母,结果触发了"切换到平移模式"。解决方案是判断当前焦点元素:
window.addEventListener('keydown', (e) => { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; // 处理快捷键 });这个判断要放在最前面,否则所有快捷键都会在输入时误触发。
坑六:模型加载的坐标系差异。不同来源的模型可能使用不同的坐标系(Y轴向上或Z轴向上)。加载后如果发现模型躺倒了,不要急着旋转,先检查模型的up属性。有些格式(如某些CAD导出)默认Z轴向上,需要手动调整。
10. 后续可以扩展的方向
这个编辑器骨架跑通之后,有几个方向可以继续深挖。一是多选与成组,支持框选多个物体并打组,这需要扩展 SelectionManager 支持集合操作。二是材质节点编辑,用可视化节点图替代简单的颜色选择器,这需要引入节点编辑器库。三是协同编辑,多人同时编辑一个场景,这涉及操作变换(OT)或冲突无关复制数据类型(CRDT)的算法。四是模型导入导出,支持 glTF、OBJ、FBX 等格式的互转。
每个方向都可以独立展开成一个大话题,但前提是先把基础骨架搭稳。我个人的经验是,不要一开始就追求功能全面,先把"选中-变换-保存"这条主链路跑通,剩下的功能都是在这条链路上做加法。主链路不稳,加再多功能也是空中楼阁。