资讯动态

Three.js 3D场景编辑器核心实现与二次开发实战

发布时间:2026/8/31 23:12:33 来源:尧图企业网站定制
简介这是一套基于Three.js构建的Web端3D场景编辑器完整源码面向前端开发者、三维可视化工程师及WebGL初学者旨在降低3D场景构建门槛无需深入WebGL底层即可快速实现交互式三维环境搭建与调试。资源共129个文件包含10个核心JS脚本含Three.js库及自定义编辑逻辑、8个GLTF格式模型支持PBR材质与动画、84张JPG与17张PNG纹理贴图、5个CSS样式文件覆盖UI控件、颜色选择器、滑块等交互组件以及HDR环境光、WASM加载模块等关键资源整体包体达130.84MB。已有2455人学习下载代码结构清晰js目录封装场景管理、对象操作与材质替换逻辑models与textures分别存放可复用资产HTML入口文件集成相机控制、光照调节与对象层级管理功能。读者可直接运行调试掌握Scene/Camera/Geometry/Material/Light/Renderer六大核心概念的实际应用并复用其模块化设计快速拓展数字孪生、虚拟展厅或教育仿真等项目。 我第一次拿到这个基于Threejs 3D场景编辑器代码.zip的时候第一反应不是急着解压而是先问了自己一个问题我到底是要在这里改一段 Three.js 的 demo还是做一个真正能用的三维内容编辑工具这个问题如果没想清楚后面所有代码都会越看越晕。这个压缩包里的东西本质上是一个用 Three.js 搭起来的可视化场景编辑器。你可以把它理解成浏览器里的轻量版 Unity 场景窗口左侧是场景对象树中间是 3D 视口右边是属性面板能拖动物体、改坐标、保存整个场景并再次打开。对做 Three.js 项目的人来说这样一个编辑器能省掉大量写死坐标的调试时间对做毕设或前端进阶练习的人来说它也是一个非常适合拆解的 Three.js 工程。这篇文章我不会只把文件列表念一遍而是按需求 - 架构 - 核心模块 - 序列化 - 踩坑 - 二次开发这条线把代码包里的关键逻辑还原出来。就算你之前没系统接触过 Three.js读下来也能明白场景编辑器是怎么运转的有 Three.js 基础的同学可以直接跳到第 3 节和第 5 节对照实现细节排查问题。1. 拿到代码包之后先分清编辑器到底要做什么1.1 我们要做的不是三五行代码的渲染 Demo而是一个三维内容生产工具Three.js 本身是一个渲染引擎不是编辑器。它可以帮你把一立方体、一盏灯、一个相机渲染到网页上但如果你想让项目里的同事或者用户能够自己搭建场景、摆放物体、调整参数、保存状态就必须在 Three.js 之上再套一层“编辑器逻辑”。所谓编辑器逻辑核心是四块场景数据模型场景里有哪些物体、它们之间的父子关系、每个物体的几何和材质信息。视口交互鼠标旋转视角、选中物体、拖拽移动、缩放旋转也就是 W/E/R 那一套操作。属性面板选中一个对象后能看到并修改它的坐标、旋转、缩放、名称、可见性等属性。序列化把当前场景保存成 JSON 文件下次重新加载后还原出一样的画面。这四件事不是独立的。视口里拖拽一下场景数据要跟着变属性面板也要立刻刷新属性面板里输入一个坐标视口里的物体现身也要立刻移动。数据流、事件流、渲染循环三条线要串起来才是“编辑器”而不是“Three.js 例程集合”。1.2 先圈定 MVP哪些功能是必需品哪些是可选项很多初学者拿到的场景编辑器代码包里面塞满了各种炫酷功能动辄十几个文件、几千行代码看起来很高大上但实际上问题就出在“什么都想做”上面。如果你准备自己从头撸一个或者要把这个 zip 里的代码整理成自己的项目我建议你先圈定一个 MVP 范围。最核心的功能可以列成一张表功能模块是否属于 MVP说明场景树展示与选中是展示 Object3D 层级点击切换选中状态透视相机 轨道控制器是自由旋转、缩放、平移视角对象选择 包围盒高亮是让你知道当前到底选中了什么平移/旋转/缩放控制器是TransformControls编辑器灵魂属性面板位置/旋转/缩放/名称是核心编辑能力添加基础几何体Box、Sphere、Plane是没有这个就无法验证编辑器能力删除/复制对象是基本对象管理场景 JSON 保存与加载是无序列化就不叫编辑器灯光与相机配置面板否后续加上即可GLTF 模型导入拖拽否需要处理资源加载、路径、DRACO 解码Undo/Redo 历史栈否架构上要预留但一期可以取舍材质编辑器、动画时间线否属于大工程和核心编辑器可以分离我的习惯是先把“增删改查 保存加载”跑通再考虑花里胡哨的东西。如果你手里的 zip 包动辄出现“材质编辑器”、“动画曲线”这些目录不要慌大多数情况下那些只是支线功能主线的入口通常叫SceneEditor或者Editor.vue先把它找到。2. 工程骨架与技术选型为什么我选择 Vue3 Three.js 而不是纯 JS 方案2.1 技术组合与目录结构Three.js 社区里现在的编辑器案例最常见的技术组合是 Vue3 Vite TypeScript Three.js。不排除 React 阵营但 Vue 的响应式数据对属性面板这种选中对象 - 表单编辑 - 写回对象的场景非常自然。一个典型代码包的目录结构长这样scene-editor/ ├─ public/ │ ├─ models/ # 外部模型资源 │ └─ draco/ # 解码器文件GLTF 压缩模型需要 ├─ src/ │ ├─ main.ts # 应用入口 │ ├─ App.vue # 根组件 │ ├─ editor/ │ │ ├─ SceneEditor.ts # 编辑器核心类持有 renderer/scene/camera/controls │ │ ├─ serializers/ │ │ │ ├─ ProjectFile.ts # 自定义工程文件解析与生成 │ │ │ └─ ObjectLoader.ts # 封装 THREE.ObjectLoader │ │ └─ utils/ │ │ ├─ dispose.ts # 几何体/材质/纹理释放工具 │ │ └─ tree.ts # 场景树遍历与生成 │ └─ components/ │ ├─ Viewport.vue # 3D 视口容器 │ ├─ SceneTree.vue # 左侧场景树 │ ├─ PropertyPanel.vue # 右侧属性面板 │ └─ AssetPanel.vue # 可选资产面板 ├─ package.json └─ vite.config.ts这个结构里最重要的一点是核心 Editor 逻辑不依赖 Vue。SceneEditor.ts是一个纯 TypeScript 类它只负责创建渲染器、场景、相机、控制器并对外暴露操作方法。Vue 组件只是它的外壳负责把它挂载到 DOM 上、展示场景树、渲染属性表单。为什么要这样分我有很深的体会。如果你直接把 Three.js 对象塞进 Vue 的reactive()或者ref()里面那基本是在给自己挖坑。Three.js 的Object3D对象内部有大量循环引用和缓存Vue 的响应式代理在深度代理它的属性时很容易出现性能下降、循环引用报错甚至某些内部 setter 被劫持后导致渲染异常。正确做法是让 Vue 管理自己的 UI 状态Three.js 管理自己的场景状态两者之间通过一个事件桥接层同步。2.2 响应式数据与 Three.js Object3D 之间的桥接设计编辑器里最常用的一个操作就是点击场景树中的对象在属性面板修改它的 position然后视口中的物体跟着移动。如果直接在 Vue 组件里写const obj reactive(mesh) obj.position.x 10 // 这样真的能驱动 Three.js 内部变化但问题在于mesh是THREE.Mesh的实例reactive(mesh)会尝试代理它的所有属性包括parent、children、userData、material等。这些对象之间互相引用Vue 的 Proxy 会把它们全量代理编辑器场景一复杂卡顿就是必然的。我采用的桥接方案是维护一个selectedData作为面板模型的普通对象import { reactive, watch } from vue; interface PanelModel { name: string; visible: boolean; position: { x: number; y: number; z: number }; rotation: { x: number; y: number; z: number }; scale: { x: number; y: number; z: number }; } const panelModel reactivePanelModel({ name: , visible: true, position: { x: 0, y: 0, z: 0 }, rotation: { x: 0, y: 0, z: 0 }, scale: { x: 1, y: 1, z: 1 }, }); let activeObject: THREE.Object3D | null null; export function selectObjectByUuid(uuid: string) { activeObject scene.getObjectByProperty(uuid, uuid); if (!activeObject) return; // 把 Object3D 的数据拷贝到普通对象避免响应式代理污染 Three.js 对象 panelModel.name activeObject.name || activeObject.type; panelModel.visible activeObject.visible; panelModel.position.x activeObject.position.x; panelModel.position.y activeObject.position.y; panelModel.position.z activeObject.position.z; panelModel.rotation.x activeObject.rotation.x; panelModel.rotation.y activeObject.rotation.y; panelModel.rotation.z activeObject.rotation.z; panelModel.scale.x activeObject.scale.x; panelModel.scale.y activeObject.scale.y; panelModel.scale.z activeObject.scale.z; } watch( () [ panelModel.position.x, panelModel.position.y, panelModel.position.z, ], () { if (activeObject) { activeObject.position.set( panelModel.position.x, panelModel.position.y, panelModel.position.z ); } } );注意这里有一个细节rotation 如果直接存欧拉角面板里用户手动输入角度时写成弧度会让人困惑。比较友好的做法是在展示层把弧度转成角度在写入层把角度转回弧度。代码包里很可能用了一个 computed 或者工具函数来做这件事。我在这里建议你单独抽一个toDegrees / toRadians的转换不要在组件里到处写* Math.PI / 180不然很快会乱。2.3 把 Editor 做成纯 TS 类不只是为了优雅SceneEditor.ts核心类承担的事很多但不复杂。它要做的事包括创建渲染器、创建场景与相机、初始化轨道控制、初始化 TransformControls、启动渲染循环、提供添加/删除/选中对象的 API。我在实际项目里是这样写的import * as THREE from three; import { OrbitControls } from three/examples/jsm/controls/OrbitControls.js; import { TransformControls } from three/examples/jsm/controls/TransformControls.js; export class SceneEditor { readonly renderer: THREE.WebGLRenderer; readonly scene: THREE.Scene; readonly camera: THREE.PerspectiveCamera; readonly orbit: OrbitControls; readonly transform: TransformControls; private selectedObject: THREE.Object3D | null null; constructor(container: HTMLElement) { // 渲染器 this.renderer new THREE.WebGLRenderer({ antialias: true }); this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); this.renderer.shadowMap.enabled true; this.renderer.shadowMap.type THREE.PCFSoftShadowMap; // 注意Three.js r152 之后用 outputColorSpace而不是 outputEncoding this.renderer.outputColorSpace THREE.SRGBColorSpace; container.appendChild(this.renderer.domElement); // 场景和相机 this.scene new THREE.Scene(); this.camera new THREE.PerspectiveCamera(45, 1, 0.1, 1000); this.camera.position.set(8, 6, 10); this.camera.lookAt(0, 0, 0); // 轨道控制 this.orbit new OrbitControls(this.camera, this.renderer.domElement); this.orbit.enableDamping true; // 变换控制 this.transform new TransformControls(this.camera, this.renderer.domElement); this.transform.addEventListener(dragging-changed, (event: any) { // 拖拽控制柄时要禁用轨道控制否则两者会打架 this.orbit.enabled !event.value; }); this.scene.add(this.transform.getHelper()); } resize(width: number, height: number) { this.renderer.setSize(width, height); this.camera.aspect width / height; this.camera.updateProjectionMatrix(); } startLoop() { this.renderer.setAnimationLoop(() this.render()); } render() { this.orbit.update(); this.renderer.render(this.scene, this.camera); } select(uuid: string) { const obj this.scene.getObjectByProperty(uuid, uuid); if (!obj) return; this.selectedObject obj; this.transform.detach(); this.transform.attach(obj); } }这个类的好处是除了Viewport.vue会调用new SceneEditor()之外其他场景树、属性面板这些组件都可以直接拿到这个实例通过类暴露的方法操作场景。后续如果你要写单元测试只需要构造一个空容器不需要依赖任何 Vue 环境。3. 场景树、属性面板、视口操作三个核心模块的具体实现3.1 场景树生成递归遍历 Object3DThree.js 场景本质上是一棵对象树。scene.add(mesh)后mesh就是scene的子节点你也可以把多个物体放在一个THREE.Group里再将 Group 加入 scene。场景树组件要做的就是把scene.children递归展示成树状列表。一个实用的生成函数import * as THREE from three; export interface SceneTreeNode { id: string; label: string; type: string; visible: boolean; children: SceneTreeNode[]; } export function buildSceneTree(root: THREE.Object3D): SceneTreeNode[] { return root.children.map((child) buildNode(child)); } function buildNode(obj: THREE.Object3D): SceneTreeNode { return { id: obj.uuid, label: obj.name || obj.type, type: obj.type, visible: obj.visible, children: obj.children.map(buildNode), }; }注意这里有两个容易踩的坑第一节点 id 必须使用object.uuid不要用索引或者我们自己在编辑器里生成的 id。uuid是 Three.js 对象天生的唯一标识序列化加载后也不会变只要你不手动重新赋值用它做选中、删除、查找的 key 是最稳的。第二label要用obj.name || obj.type。大多数人新建一个 Box 后根本不会给它命名如果只显示name整个场景树会出现一堆空字符串完全无法区分。显示type可以快速看出它是一个Mesh还是Light但这还不够友好。更好的做法是后面加上可编辑名称功能双击节点时修改obj.name。场景树数据的刷新不需要做成响应式。我见过有些实现会监听 scene 的add/remove事件Three.js 的 EventDispatcher 是支持的但这在高频操作时很容易触发多余的重渲染。更简单的做法是维护一个treeVersion计数器const treeVersion ref(0); export function refreshSceneTree() { treeVersion.value; } watch(treeVersion, () { treeData.value buildSceneTree(editor.scene); });只要添加物体、删除物体、重命名这些操作发生时手动调一次refreshSceneTree()即可。场景树节点不多时全量重建完全没有性能问题。只有当你场景里塞了几千个节点还要求实时刷新时才需要去做局部 diff。3.2 属性面板同步不要每帧同步要事件同步属性面板是编辑器里最容易做得乱七八糟的地方。很多初版代码会直接在渲染循环里把选中对象的 position 同步到面板每帧更新几十次结果就是用户想输入数字的时候输入框里的值一直在跳光标都放不稳。正确思路是只在两种时机同步面板一是选中对象变化时二是 TransformControls 拖拽过程中发生 objectChange 事件时。第一种时机前面已经讲了调用selectObjectByUuid后把属性拷贝到 panelModel。第二种时机需要监听 TransformControls 的事件this.transform.addEventListener(objectChange, () { if (this.selectedObject) { // 把最新位置同步到 panelModel updatePanelFromSelected(); } });这样用户在视口里拖动方块时属性面板的数字会跟着变用户直接在面板输入数值时watch 会反向写回 Object3D。整个环路的触发点非常明确不会产生循环更新。属性面板的模板代码大致是template div v-ifowner classproperty-panel label名称/label input v-modelpanelModel.name changerenameObject / label可见/label input typecheckbox v-modelpanelModel.visible changetoggleVisible / div classsection位置/div div classrow labelX/label input typenumber v-model.numberpanelModel.position.x / labelY/label input typenumber v-model.numberpanelModel.position.y / labelZ/label input typenumber v-model.numberpanelModel.position.z / /div div classsection旋转度/div div classrow labelX/label input typenumber v-model.numberpanelModel.rotationDeg.x / labelY/label input typenumber v-model.numberpanelModel.rotationDeg.y / labelZ/label input typenumber v-model.numberpanelModel.rotationDeg.z / /div div classsection缩放/div div classrow labelX/label input typenumber v-model.numberpanelModel.scale.x step0.1 / labelY/label input typenumber v-model.numberpanelModel.scale.y step0.1 / labelZ/label input typenumber v-model.numberpanelModel.scale.z step0.1 / /div /div /template这里有个我从实际项目里踩出来的坑v-model.number在用户清空输入框时会得到一个空字符串而不是数字0然后position.set(, 0, 0)就会直接把坐标设成 NaN。所以如果你用v-model.number最好在写入 Object3D 的函数里做一次Number.isFinite校验防止脏数据进入 Three.js 内部。代码包里如果没处理建议加上。3.3 视口中的变换与相机控制协同TransformControls 是 Three.js 官方提供的平移/旋转/缩放控制工具开箱即用。但它有一个非常经典的冲突当你按住鼠标拖动物体的控制柄时OrbitControls 也会同时响应导致视角也在转。解决办法就是前面SceneEditor构造器里那段代码this.transform.addEventListener(dragging-changed, (event: any) { this.orbit.enabled !event.value; });dragging-changed事件会在用户开始拖拽控制柄时触发{ value: true }松开时触发{ value: false }。把orbit.enabled关掉就能保证拖拽物体时相机不动。除此之外选中高亮也是编辑器里很影响体验的细节。最简单的做法是用THREE.BoxHelper选中物体时创建一个包围盒辅助对象加入场景取消选中时移除。代码很轻量import * as THREE from three; let boxHelper: THREE.BoxHelper | null null; function highlightObject(obj: THREE.Object3D) { if (boxHelper) { scene.remove(boxHelper); boxHelper.geometry.dispose(); (boxHelper.material as THREE.Material).dispose(); } boxHelper new THREE.BoxHelper(obj, 0x3399ff); scene.add(boxHelper); }BoxHelper会自动根据对象的包围盒生成线框盒子而且如果物体被移动只要调用一次boxHelper.update()就会跟上。如果你想让它在 TransformControls 拖拽时也保持贴合可以在渲染循环里加if (boxHelper) boxHelper.update();这个开销很小因为 BoxHelper 内部会调用setFromObject计算包围盒对象数量不多时完全没问题。如果你有 DragControls 拖拽资产进场景的需求记得它和 TransformControls 不能同时操作同一个对象。我的做法是DragControls 只负责把资源从左侧资产面板拖到视口里拖拽结束时生成一个新对象并加入场景视口内选中对象的后续操作全部交给 TransformControls。4. 场景序列化设计把编辑器状态变成可保存、可加载的工程文件4.1 直接用 scene.toJSON() 够吗Three.js 的scene.toJSON()是一个非常有用的接口。它会递归遍历场景把场景内的相机、灯光、几何体、材质、纹理、userData全部序列化成 JSON 对象。然后你可以用THREE.ObjectLoader的parse方法从 JSON 还原出场景。但如果你直接拿scene.toJSON()当工程文件格式会面临几个问题第一它不包含项目的业务元信息。你保存的是一个叫我的场景的编辑工程不光需要 Three.js 场景数据还需要工程名、作者、创建时间、版本号、缩略图等。第二它对外部资源的处理不够灵活。scene.toJSON()会把纹理图片转成 base64 塞进 JSON这对小图片没问题但一张 8K 的 HDR 环境贴图会直接让 JSON 文件膨胀到几十 MB保存、加载、版本管理全都变得痛苦。第三它缺少你自定义扩展的语义。比如你后面想做选中物体后的编辑器交互配置这些数据如果只是塞进userData在多人协作或版本升级时会非常含糊。所以我建议的做法是基于scene.toJSON()做一个工程文件包装层而不是直接使用。4.2 自定义工程文件格式与版本字段工程文件的结构可以定义成这样export interface ProjectFile { editor: three-scene-editor; version: number; // 工程格式版本号整数递增 meta: { name: string; createdAt: string; updatedAt: string; author?: string; }; scene: object; // scene.toJSON() 的结果 resources?: ResourceRecord[]; // 外部模型、纹理的引用列表 }为什么要严格加上version字段因为编辑器是会持续升级的。第一版你可能只用mesh.position和mesh.scale第二版你可能会给物体增加一个userData.physics字段第三版你可能会调整光照模型的存储结构。如果没有版本号加载旧文件时你会完全无法判断该按什么逻辑解析。有了版本号就可以在加载函数里写分版本迁移逻辑function migrateProjectFile(raw: any): ProjectFile { let file raw as ProjectFile; if (file.version 1) { // 第一版迁移逻辑例如把 rotation 从弧度统一成角度 file migrateV1toV2(file); } if (file.version 2) { // 第二版迁移逻辑 } return file; }保存工程的函数大概长这样import * as THREE from three; export function toProjectFile(scene: THREE.Scene, name: string): ProjectFile { return { editor: three-scene-editor, version: 1, meta: { name, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }, scene: scene.toJSON(), }; } export function fromProjectFile(file: ProjectFile): THREE.Scene { const loader new THREE.ObjectLoader(); const scene loader.parse(file.scene) as THREE.Scene; return scene; }对于保存逻辑还有一个在编辑器里非常关键的细节scene.toJSON()序列化的是当前整棵场景树包括编辑器自己添加的 TransformControls helper、BoxHelper 这些辅助对象吗答案是如果你直接scene.add(transform.getHelper())确实会被序列化进去。但这通常不是你想要的结果。所以保存前的标准操作是先把辅助对象从场景中临时移除再调用 toJSON保存完再重新加回来。export function toProjectFileWithHelpers( scene: THREE.Scene, helpers: THREE.Object3D[], name: string ): ProjectFile { const helperParents helpers.map((h) { const parent h.parent; parent?.remove(h); return parent; }); const file toProjectFile(scene, name); helpers.forEach((h, index) { helperParents[index]?.add(h); }); return file; }这个细节如果你没处理加载出来的场景里会出现一个 TransformControls 的 gizmo甚至可能导致运行时控制冲突。4.3 加载流程与模型资源处理编辑器里除了基础几何体大概率还会允许用户导入外部模型最常见的是 GLTF 格式。GLTF 模型加载回来的是一个THREE.Group节点里面是 Mesh、材质、贴图等。加载流程和ObjectLoader完全不同因为模型文件本身是外部资源你需要自己管理 URL 和加载进度。一个简化的 GLTF 导入函数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(); // 注意这是最常见的坑之一 // DRACO 解码器目录要指向 public/draco 或者 node_modules 里拷贝出来的解码文件 dracoLoader.setDecoderPath(/draco/); gltfLoader.setDRACOLoader(dracoLoader); export async function loadGltf(url: string): PromiseTHREE.Group { const gltf await gltfLoader.loadAsync(url); const model gltf.scene; // 设置一个默认名称便于场景树识别 model.name model.name || GLTF_${Date.now()}; return model; }拿到模型后你会遇到一个绕不开的问题模型尺寸和单位不匹配。有些三维软件默认单位是厘米甚至毫米导出的 GLTF 模型导入 Three.js 可能是几十上百的单位大小而默认相机 far 只有 1000near 只有 0.1结果就会看到画面花屏或者物体只在镜头很近的时候才出现。我建议在导入模型后立即计算包围盒并对模型做一次归一化处理import * as THREE from three; export function normalizeModelToUnit(model: THREE.Object3D, targetSize 2) { const box new THREE.Box3().setFromObject(model); const size box.getSize(new THREE.Vector3()); const maxDim Math.max(size.x, size.y, size.z); if (maxDim 1e-6) return; const scale targetSize / maxDim; model.scale.setScalar(scale); // 把模型中心点放到原点方便编辑 const center box.getCenter(new THREE.Vector3()); model.position.sub(center.clone().multiplyScalar(scale)); }这一步对编辑器体验提升非常大。导入一个 300 米高的模型你根本没法在默认相机下正常编辑。归一化到最长边长约为 2 个单位后再配合相机lookAt(0, 0, 0)基本打开就能看到一个舒服的视角。5. 跑通代码后最容易踩的坑性能、颜色、模型单位与事件冲突5.1 颜色空间与渲染器设置一上来就看到的真实差异如果你导入新版本的 Three.js 代码最常见的一个问题就是场景里的颜色发灰、偏暗或者整个画面和模型预览器里看到的不一致。这个八成是颜色空间配置的问题。Three.js r152 之后默认颜色空间发生了变化。最稳妥的写法是renderer.outputColorSpace THREE.SRGBColorSpace; renderer.toneMapping THREE.ACESFilmicToneMapping; renderer.toneMappingExposure 1.0;对于贴图很多加载器已经能自动识别颜色空间。但如果你手动创建的CanvasTexture或者从DataTexture生成贴图记得要设置texture.colorSpace THREE.SRGBColorSpace;如果你看到的代码包里有renderer.outputEncoding THREE.sRGBEncoding这种写法说明它是在 r152 之前写的。这次升级非常坑因为旧写法在控制台不会报错只是颜色不对你很难定位到是这行代码的问题。我在排查项目的时候就因为这个问题浪费过一下午最后是挨个注释渲染器设置才发现是新旧 API 替换导致的。5.2 阴影视图问题为什么我加了阴影却看不到给编辑器加阴影是另一个高频踩坑点。要在 Three.js 中显示阴影至少需要同时满足四个条件renderer.shadowMap.enabled true灯光设置castShadow true并配置阴影相机范围投射阴影的物体设置castShadow true接收阴影的地面/物体设置receiveShadow true四个条件缺一个都看不见阴影。我看过很多代码包前两个都做了但地面忘了receiveShadow或者某个模型是外部导入的内部 mesh 没有设置castShadow。你还要注意外部导入的 GLTF 模型其内部的 Mesh 并不天然继承父级 Group 的阴影设置你必须遍历所有子节点去设置model.traverse((child) { if (child instanceof THREE.Mesh) { child.castShadow true; child.receiveShadow true; } });如果阴影边缘出现很严重的锯齿可以把renderer.shadowMap.type改成THREE.PCFSoftShadowMap再把主方向光的 shadow mapSize 调高到 2048。但不要盲目调高mapSize是平方级增长2048 已经是很多场景的常用值4096 对性能影响就明显了。5.3 删除对象和释放内存你以为删了其实 GPU 里还躺着编辑器里用户会频繁添加、删除物体如果删除操作只写一句scene.remove(obj)那用不了多久编辑器就会越用越卡。因为scene.remove(obj)只是把对象从场景图里摘除对象关联的几何体Geometry、材质Material、纹理Texture仍然存在于 GPU 显存里。你需要显式调用dispose()。我封装了一个释放工具函数import * as THREE from three; export function disposeObject(obj: THREE.Object3D) { obj.traverse((child) { if (child instanceof THREE.Mesh) { if (child.geometry) { child.geometry.dispose(); } const materials Array.isArray(child.material) ? child.material : [child.material]; materials.forEach((mat) { if (!mat) return; // 释放纹理贴图 if (mat.map) mat.map.dispose(); if (mat.emissiveMap) mat.emissiveMap.dispose(); if (mat.normalMap) mat.normalMap.dispose(); if (mat.roughnessMap) mat.roughnessMap.dispose(); if (mat.metalnessMap) mat.metalnessMap.dispose(); mat.dispose(); }); } }); obj.parent?.remove(obj); }同时如果删除的正好是选中对象要调用transform.detach()不然 TransformControls 还引用着一个不在场景里的对象后续操作会出现各种诡异问题。这个我在项目里也遇到过删除一个物体后再点击场景空白处gizmo 还顽固地出现在原来的位置上就是因为没 detach。5.4 性能优化不需要做得很极致但要防止明显卡顿编辑器开发中性能优化并没有想象中那么复杂关键是把握住几个容易出问题的地方第一如果一个场景里有大量相同的几何体比如几百个同样的立方体考虑用InstancedMesh而不是创建几百个Mesh对象。每个Mesh都是一次 draw call而InstancedMesh十几行代码就能把 draw call 降到一次。第二渲染循环用renderer.setAnimationLoop而不是requestAnimationFrame。setAnimationLoop在 WebXR 兼容上更好也方便统一管理暂停和恢复。第三如果场景中有大量静态物体可以在它们放置完成后设置matrixAutoUpdate false并在需要时手动调用updateMatrixWorld()。这个优化对大型场景效果明显但对编辑器来说因为经常要拖动默认还是不要关。第四注意renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))。在 MacBook Pro 这种 3x pixel ratio 的屏幕上如果不限制最大 2渲染压力会成倍增加而且肉眼几乎看不出 2x 和 3x 的差别。6. 一个编辑器代码包从读懂到二次开发的扩展思路6.1 先加 Undo/Redo 还是先接入模型导入如果你已经跑通了核心流程准备对这个代码包做二次开发我建议第一个扩展功能做Undo/Redo而不是急着接更多模型格式。原因很简单编辑器里最有价值、最能让使用者安心的功能不是功能多而是操作错了能撤销。Undo/Redo 的最小实现不需要很复杂可以先把每次操作前的场景完整序列化存下来然后在栈顶记录操作名撤销时 pop 出上一份快照并整树替换场景。快照方式对小型编辑器完全够用import { toProjectFile } from ./serializers; const undoStack: ProjectFile[] []; const redoStack: ProjectFile[] []; export function pushHistory(editor: SceneEditor) { undoStack.push(toProjectFile(editor.scene, history)); if (undoStack.length 50) undoStack.shift(); // 限制栈深度 redoStack.length 0; } export function undo(editor: SceneEditor) { const prev undoStack.pop(); if (!prev) return; redoStack.push(toProjectFile(editor.scene, history)); const rebuilt fromProjectFile(prev); // 用重建的 scene 替换当前 scene并重建场景树 editor.replaceScene(rebuilt); }等你把 Undo/Redo 理顺了再接 GLTF 导入、DragControls、多选、复制粘贴都会顺手很多。因为只要修改场景数据前都pushHistory任何操作都能回退。6.2 如果只保留一条经验从拿到基于Threejs 3D场景编辑器代码.zip这份代码到我自己重新搭出一个稳定的编辑器这一路踩过的坑里如果只能保留一条经验我会说先把数据模型和事件模型设计好再写界面。很多场景编辑器代码包之所以越改越乱是因为操作路径太多场景树点击、视口点击、属性面板输入、TransformControls 拖拽、代码直接调用每一条路径都可能修改同一个 Object3D 的 position。如果你没有统一的事件桥接层很快就会出现属性面板里改了数字但场景树没刷新gizmo 位置也停在原地这种问题。我建议你在任何需要修改场景数据的地方都走同一个入口editor.registerChange(position, object, newValue);内部把这次变更派发给场景树、属性面板、辅助高亮、Undo 栈这样不管用户是通过哪种方式改的所有 UI 都能同步更新。等你真正做完这一步这个代码包才算真正变成了你自己的工具而不是只停留在能跑的阶段。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价