💡 写在前面
为了让大家更好地本地体验编辑器,目前我们已开源了除 SDK 核心和 AI 功能以外的所有模块。 编辑器在线体验地址:editor.moyunhe.com/resource-pl…
- 🔗 GitHub 地址: blueRaining/QDesigner-Public
- ⚙️ 环境要求: Node.js
v22.18.0(建议v20+)欢迎下载体验,如果觉得有用,顺手给个 Star ⭐ 是对我们最大的鼓励!
🎯 一、设计哲学:搭积木式的功能整合
进入正题。一个通用的 3D 编辑器,看似基础的功能点其实暗藏玄机:
- 核心能力:模型加载、场景保存、灯光/环境球/背景调整。
- 进阶能力:场景压缩、后处理管线、相机配置、物体与材质属性精细调整。
- 灵魂能力:撤销/重做系统 以及 灵活的插件系统,界面UI与3D属性快速整合的能力。
这些功能单看都不难,但要让它们 “好用” 且 “易于与前端交互” ,就需要精心的架构设计。
🧱 我的核心设计哲学
“除了核心渲染循环之外,其他所有模块必须是可拆分、可组合、可删减、可替换的。”
只有做到极致的模块化,整个设计工具才具备最佳的扩展性和可成长性。在不改变核心代码的前提下,通过插件无限扩展编辑器能力,这才是我们追求的目标。
📥 二、模型加载:曲线救国的智慧
1. 痛点:BabylonJS 的原生局限
BabylonJS 对模型格式的支持相对“高冷”。官方最推荐的是 GLB/GLTF 格式。
对于 FBX, STL, B3DM 等工业常用格式,原生支持较弱。如果逐一去实现这些格式的解析器,工作量巨大且难以维护。
2. 解决方案:借力 Three.js 生态
经过多年在 Three.js 和 BabylonJS 之间的摸索,我发现了一个绝佳的“曲线救国”方案:
利用 threepipe 项目。
-
优势:这是一个集成度极高的开源项目,它魔改了 Three.js 底层,集成了多种模型格式的转换功能。
-
策略:虽然不建议直接将其用于商业生产(因为修改了 Three.js 底层,存在作者停更导致项目瘫痪的风险),但作为 “模型格式转换中间件” 简直完美,我们未在3D SDK本身集成这个转换,是在前端中引入了这个转换流程,后续有更好的转换方式也可以接入。
-
工作流:
- 利用 threepipe 将 FBX/OBJ/STL 等格式统一转换为标准的 GLB 模型。
- 将生成的 GLB 交给 BabylonJS 进行加载和渲染。
💡 提示:熟悉后端的朋友也可以自行搭建转换服务,核心思路一致:统一输入源为 GLB。
3. 模型加载关键代码:注册 GLTF Loader
BabylonJS 引擎底层本身不默认包含 GLTF 解析器,需要显式导入并注册:
typescript
编辑
// 1. 显式导入 Loader
import { GLTFFileLoader } from "loaders/glTF/glTFFileLoader";
// 2. 全局注册插件,这一步式代码内部实现的,这里拿出来只是分析原理
RegisterSceneLoaderPlugin(new GLTFFileLoader());
// 3. 统一调用入口
// 引擎内部会自动识别格式,如果是 glb/gltf 则自动使用上述 Loader
SceneLoader.LoadAssetContainerAsync(...);
📤 三、场景导出:不仅仅是保存文件
导出功能同样不是 BabylonJS 的原生“开箱即用”能力,我们需要引入 serializers 库并进行深度定制。
1. 基础导出逻辑
这是导出的核心异步方法,负责生成 GLB 数据:
typescript
编辑
public static async GLBAsync(
scene: Scene,
fileName: string,
options?: IExportOptions
): Promise<GLTFData> {
// 等待场景准备就绪
if (!options || !options.exportWithoutWaitingForScene) {
await scene.whenReadyAsync();
}
const exporter = new GLTFExporter(scene, options);
// 生成 GLB 二进制数据
const data = await exporter.generateGLBAsync(fileName.replace(/.[^/.]+$/, ""));
exporter.dispose();
return data;
}
2. 自动压缩:Draco 的福音
在 BabylonJS 8.0 版本之前,我们需要额外引入 gltf-transform 来处理 Draco 压缩。但现在,一切变得简单了。
- 内置支持:
GLTFExporter内部自带了KHR_draco_mesh_compression扩展。 - 无需手动调用:导出时会自动启用 Draco 压缩网格数据;导入时也会自动识别并解压。
Babylonjs导出文件截图:
Babylonjs导入文件结构:
🧩 四、深度定制:如何收集“非标准”数据?
官方的 Exporter 和 Serializers 只能处理标准 GLTF 数据。但作为一款编辑器,我们需要保存更多“私有”信息:
- 📷 相机数据:视角、焦距、轨道位置。
- 💡 灯光数据:自定义强度、颜色、阴影参数。
- 🎨 材质扩展:自定义 Shader 参数、SSS 设置。
- 🌍 环境配置:环境球 (HDR)、背景渐变、地面投影设置。
- 🔌 插件状态:后处理开关、UI 布局状态等。
1. 挑战与维护
为了获取这些数据,我们不得不将 BabylonJS 的 serializers 和 loaders 源码复制到项目内部进行魔改。
很多人看到“复制源码”就头大,担心难以维护。其实不然:
- 最小化修改:我们只修改与
Material(材质)、Mesh(网格) 相关的序列化逻辑。 - 隔离依赖:大部分扩展逻辑无需变动,只需调整引入 BabylonJS 核心库的方式即可。
2. 数据结构设计
为了实现模块化,我将导出数据清晰地分为两类:
🌐 全局场景数据 (Global Scene Data)
- 相机配置
- 场景自定义元数据
- 背景设置 (Background)
- 环境球 (Environment Map)
📦 局部模型数据 (Local Model Data)
- 自定义材质参数
- 模型变换与属性
- 插件专属数据 (地面投影、灯光特效、后处理链状态等)
✨ 五、神器登场:装饰器模式简化序列化
面对如此分散的数据,如何高效收集?难道要写几百个 JSON.stringify?
当然不。BabylonJS 底层强大的 装饰器 (Decorators) 机制是我们的救星。
1. 定义即收集
对于每一个需要导出的属性,我们只需添加一个简单的装饰器标记:
2. 一行代码完成序列化
有了装饰器,序列化过程变得极其优雅:
typescript
编辑
toJSON(): any {
// 利用 SerializationHelper 自动收集所有带装饰器的属性
const serializationObject = SerializationHelper.Serialize(this);
// 附加类型标识,方便反序列化时识别
serializationObject.type = (this as any).constructor.PluginType;
serializationObject.assetType = "config";
// 触发事件,通知外部系统
this.dispatchEvent({ type: "serialize", data: serializationObject });
return serializationObject;
}
3. 轻松复原 (反序列化)
恢复数据同样简单,框架会自动匹配并填充:
typescript
编辑
fromJSON(data: any): this | null | Promise<this | null> {
if (!this._viewer?.scene) {
return null;
}
// 类型校验
if (data.type !== this.constructor.PluginType) return null;
// 自动解析并赋值
SerializationHelper.Parse(() => this, data, this._viewer?.scene);
this.dispatchEvent({ type: "deserialize", data });
return this;
}
✅ 效果:通过这套机制,原本可能需要上千行的数据收集代码,现在几百行即可搞定,且类型安全,易于维护,上面提到的不同类别的场景信息,都可以使用装饰器的方式收集和恢复。
🖼️ 六、进阶优化:图片资源的存储策略
在序列化过程中,背景图和环境球 (HDR) 是两个特殊的“体积杀手”。
如果处理不当,一个原本几百 KB 的模型文件,加上这两张大图后,瞬间膨胀到几 MB 甚至几十 MB。
我们有两种处理思路:
方案 A:外链存储 (强烈推荐 ⭐)
- 做法:将背景图和环境球图片上传到 CDN 或对象存储,序列化数据中只保存 URL 链接。
- 优点:极大减少 GLB 文件体积,加载速度快,便于资源复用。
- 适用:绝大多数 Web 应用场景。
方案 B:内嵌二进制 (BufferView)
-
做法:将图片转换为二进制流,像普通纹理一样存储到 GLB 的
bufferView中。 -
实现逻辑:
typescript
编辑
private _exportImage(name: string, mimeType: ImageMimeType, data: ArrayBuffer): number { const images = this._exporter._images; let image: IImage; if (this._exporter._shouldUseGlb) { // GLB 模式:存入 BufferView image = { name: name, mimeType: mimeType, bufferView: undefined, // 后续由 BufferManager 填充 }; const bufferView = this._exporter._bufferManager.createBufferView(new Uint8Array(data)); this._exporter._bufferManager.setBufferView(image, bufferView); } else { // GLTF + 外部图片模式 const baseName = name.replace(/./|/|.\|\/g, "_"); const extension = GetFileExtensionFromMimeType(mimeType); let fileName = baseName + extension; // 防止文件名冲突 if (images.some((image) => image.uri === fileName)) { fileName = `${baseName}_${Tools.RandomId()}${extension}`; } image = { name: name, uri: fileName }; this._exporter._imageData[fileName] = { data: data, mimeType: mimeType }; } images.push(image); return images.length - 1; } -
优点:单文件交付,无需担心外链失效。
-
缺点:文件体积大,解析慢。
🗜️ 七、终极压缩:WebP 格式改造
模型文件的体积主要由两部分决定:
- 几何信息:已通过 Draco 完美解决。
- 图片纹理:这是剩下的瓶颈。
众所周知,WebP 格式在同等画质下体积远小于 PNG/JPG。我们需要改造 GltfExporter 以支持 WebP 输出。别担心,利用 BabylonJS 的工具函数非常简单:
typescript
编辑
// 使用 DumpTools 进行异步格式转换
const webpData = await DumpTools.DumpDataAsync(
size.width,
size.height,
data, // 原始图像数据
"image/webp", // 目标格式
undefined,
false,
true,
quality // 质量系数
) as ArrayBuffer;
⚖️ 质量系数 (Quality) 的选择
-
常规纹理:设置为
0.85。肉眼几乎无损,体积减小显著。 -
法线贴图 (Normal Map) :强烈建议设置为
1.0。⚠️ 注意:法线贴图对精度极其敏感,0.85 的压缩可能会导致光照计算出现伪影或噪点,务必保留最高质量。
🏁 八、结语
以上就是我关于整个编辑器导入、导出、压缩、场景数据收集以及图片优化的核心实现思路。
从模型格式的“曲线救国”,到利用装饰器优雅地解决复杂数据序列化,再到极致的体积压缩,每一步都是为了让开发者能从繁琐的底层细节中解脱出来,专注于创意本身。
当然,将一个编辑器打磨到极致是一个庞大的工程,本文仅抛砖引玉,列出了最关键的技术路径。如果你有更巧妙的思路,或者在实施过程中遇到了有趣的挑战,欢迎在评论区一起交流!
👉 再次提醒:完整代码已开源,欢迎 Star 支持!
GitHub: blueRaining/QDesigner-Public
整套工具源码也支持出售,用于训练你的专属WEB3D的skills 下一章开始一一介绍,如何优雅的设计UI和3D交互
欢迎加入我们的交流群,一起探索 Web 3D 的无限可能!
欢迎加入我们的知识星球,知识星球将会拥有全套的技术细节实现讲解!