普通视图

发现新文章,点击刷新页面。
昨天以前首页

three.js三维场景内容数据存储方案(indexedDB/toJSON)

作者 答案answer
2025年4月17日 10:04

前言

相信大家在使用three.js中开发过基于3D模型数据内容编辑功能时可能会遇到这样一些问题吧。

当你辛苦半天将一个模型场景效果如:相机角度,材质贴图,金属度,粗糙度,自发光,灯光,曝光度,色调映色,场景环境光等参数调至最佳后,因为页面关闭或者刷新导致重新进入页面需要重新去编辑调试数据模型场景,又或者需要手动修改代码默认参数值时,这种情况无论是对于开发者和使用者来说都是一个痛苦的过程。

基于这个需求背景作者也尝试探索了一下three.js三维场景内容数据的存储方案 ↓

一. indexedDB 存储方案(Vue3项目)

three.js提供了将场景数据内容转化为json数据格式的API(toJSON),通过将当前场景(scene)数据转换为json数据然后在存储到indexedDB

1.为了方便操作indexedDB这里我们对其进行单独封装
/**
 * 数据库工具类
 */
export default class IndexDBUtil {
  private dbName: string;
  private version: number;
  private db: IDBDatabase | null;

  constructor(dbName: string, version: number = 1) {
    this.dbName = dbName;
    this.version = version;
    this.db = null;
  }

  /**
   * 初始化数据库
   * @param stores - 对象仓库
   * @returns 是否初始化成功
   */
  async initDB(stores: { name: string; keyPath: string }[]): Promise<boolean> {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(this.dbName, this.version);

      request.onerror = () => {
        reject(new Error('数据库打开失败'));
      };

      request.onsuccess = (event) => {
        this.db = (event.target as IDBOpenDBRequest).result;
        resolve(true);
      };

      request.onupgradeneeded = (event) => {
        const db = (event.target as IDBOpenDBRequest).result;

        // 创建对象仓库
        stores.forEach((store) => {
          if (!db.objectStoreNames.contains(store.name)) {
            db.createObjectStore(store.name, { keyPath: store.keyPath });
          }
        });
      };
    });
  }

  /**
   * 添加数据
   * @param storeName - 对象仓库名称
   * @param data - 数据
   * @returns 添加的数据
   */
  async add<T>(storeName: string, data: T): Promise<T> {
    return new Promise((resolve, reject) => {
      if (!this.db) {
        reject(new Error('数据库未初始化'));
        return;
      }

      const transaction = this.db.transaction([storeName], 'readwrite');
      const store = transaction.objectStore(storeName);
      const request = store.add(data);

      request.onsuccess = () => resolve(data);
      request.onerror = () => {
        reject(new Error('添加数据失败'));
      };
    });
  }

  /**
   * 更新数据
   * @param storeName - 对象仓库名称
   * @param data - 数据
   * @returns 更新的数据
   */
  async update<T>(storeName: string, data: T): Promise<T> {
    return new Promise((resolve, reject) => {
      if (!this.db) {
        reject(new Error('数据库未初始化'));
        return;
      }

      const transaction = this.db.transaction([storeName], 'readwrite');
      const store = transaction.objectStore(storeName);
      const request = store.put(data);

      request.onsuccess = () => resolve(data);
      request.onerror = () => reject(new Error('更新数据失败'));
    });
  }

  /**
   * 删除数据
   * @param storeName - 对象仓库名称
   * @param key - 数据键
   * @returns 是否删除成功
   */
  async delete(storeName: string, key: string | number): Promise<boolean> {
    return new Promise((resolve, reject) => {
      if (!this.db) {
        reject(new Error('数据库未初始化'));
        return;
      }

      const transaction = this.db.transaction([storeName], 'readwrite');
      const store = transaction.objectStore(storeName);
      const request = store.delete(key);

      request.onsuccess = () => resolve(true);
      request.onerror = () => reject(new Error('删除数据失败'));
    });
  }

  /**
   * 查询单条数据
   * @param storeName - 对象仓库名称
   * @param key - 数据键
   * @returns 查询的数据
   */
  async get<T>(storeName: string, key: string | number): Promise<T | null> {
    return new Promise((resolve, reject) => {
      if (!this.db) {
        reject(new Error('数据库未初始化'));
        return;
      }

      const transaction = this.db.transaction([storeName], 'readonly');
      const store = transaction.objectStore(storeName);
      const request = store.get(key);

      request.onsuccess = () => resolve(request.result as T);
      request.onerror = () => reject(new Error('查询数据失败'));
    });
  }

  /**
   * 获取所有数据
   * @param storeName - 对象仓库名称
   * @returns 所有数据
   */
  async getAll<T>(storeName: string): Promise<T[]> {
    return new Promise((resolve, reject) => {
      if (!this.db) {
        reject(new Error('数据库未初始化'));
        return;
      }

      const transaction = this.db.transaction([storeName], 'readonly');
      const store = transaction.objectStore(storeName);
      const request = store.getAll();

      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(new Error('获取所有数据失败'));
    });
  }

  /**
   * 清空对象仓库
   * @param storeName - 对象仓库名称
   * @returns 是否清空成功
   */
  async clear(storeName: string): Promise<boolean> {
    return new Promise((resolve, reject) => {
      if (!this.db) {
        reject(new Error('数据库未初始化'));
        return;
      }

      const transaction = this.db.transaction([storeName], 'readwrite');
      const store = transaction.objectStore(storeName);
      const request = store.clear();

      request.onsuccess = () => resolve(true);
      request.onerror = () => reject(new Error('清空数据失败'));
    });
  }
}

2.在需要使用的页面进行引入和初始化

页面加载时初始化连接indexedDB



<script setup>
import IndexDBUtil from '@/utils/indexedDB';
import { onMounted, ref} from 'vue';
import { cloneDeep } from 'lodash-es';
import {
  IndexDbStoreName,
  IndexDbStoreKeyPath,
  IndexDbDataName,
} from '@/enums/indexDb';
import { useIndexDbStore } from '@/store/indexDbStore';
 // 当前场景存储在pinia中
 const store = useModelStore();
 const indexDbUtil = ref<IndexDBUtil | null>()
 
 // 初始化indexedDB
  onMounted(()=>{
      indexDbUtil.value =  new IndexDBUtil(IndexDbDataName.sceneEditor);  
      indexDbUtil.value.initDB([
        {
          name: IndexDbStoreName.scene,
          keyPath: IndexDbStoreKeyPath.sceneBlobData,
        },
      ])
  })
</script>
3.保存场景数据到indexedDB

<script setup>
import IndexDBUtil from '@/utils/indexedDB';
import { onMounted, ref} from 'vue';
import { cloneDeep } from 'lodash-es';
import {
  IndexDbStoreName,
  IndexDbStoreKeyPath,
  IndexDbDataName,
} from '@/enums/indexDb';
import { useIndexDbStore } from '@/store/indexDbStore';
 // 当前场景存储在pinia中
 const store = useModelStore();
 const indexDbUtil = ref<IndexDBUtil | null>()
 
  // 初始化indexedDB
  onMounted(()=>{
      indexDbUtil.value =  new IndexDBUtil(IndexDbDataName.sceneEditor); 
      indexDbUtil.value.initDB([
        {
          name: IndexDbStoreName.scene,
          keyPath: IndexDbStoreKeyPath.sceneBlobData,
        },
      ])
  })
//保存场景到indexedDB
const saveSceneIndexDb = async () => {
  try {
    if (!store.sceneApi) {
      throw new Error('场景未初始化');
    }
    let newScene = cloneDeep(store.sceneApi?.scene);

    // 创建一个新的对象来存储序列化后的数据
    let jsonData = {
      scene: newScene?.toJSON(),
      camera: store.sceneApi.camera?.toJSON(),
    };
    let sceneInfo = {
      sceneBlobData: IndexDbStoreKeyPath.sceneBlobData,
      ...jsonData,
    }; 
  // 先查询之前是否有保存
    const oldData = await indexDbStore.indexDbUtil?.get(
      IndexDbStoreName.scene,
      IndexDbStoreKeyPath.sceneBlobData
    );
     // 如果有历史记录则更新indexedDB
    if (oldData) {
      await indexDbStore.indexDbUtil?.update(IndexDbStoreName.scene, {
        ...oldData,
        ...jsonData,
      });
    } else {
      // 如果没有历史记录则添加
      await indexDbStore.indexDbUtil?.add(IndexDbStoreName.scene, sceneInfo);
    }
  } catch (error) {
    console.error('保存场景失败:', error);
    return Promise.reject(error);
  } finally {
    return Promise.resolve();
  }
};
</script>

浏览器 F12查看数据内容 在这里插入图片描述

4. 加载indexedDB场景内容数据

three.js 提供了 ObjectLoader 加载器支持将json数据解析

将当前场景和相机等json 数据解析出来然后赋值给场景内容

<script setup>
import IndexDBUtil from '@/utils/indexedDB';
import { onMounted, ref} from 'vue';
import { cloneDeep } from 'lodash-es';
import {
  IndexDbStoreName,
  IndexDbStoreKeyPath,
  IndexDbDataName,
} from '@/enums/indexDb';
import { useIndexDbStore } from '@/store/indexDbStore';
import { ObjectLoader } from 'three';
import * as THREE from 'three';

 // 当前场景存储在pinia中
 const store = useModelStore();
 const indexDbUtil = ref<IndexDBUtil | null>()
 
 onMounted(()=>{
    // 获取indexDb场景数据
      const loadSceneData =indexDbUtil.value.get(
        IndexDbStoreName.scene,
        IndexDbStoreKeyPath.sceneBlobData
      );
     //  创建加载器(可以考虑缓存loader实例)
      const loader = new ObjectLoader();

     // 并行加载场景和相机数据
      const [parseScene, parseCamera] = await Promise.all([
        loader.parseAsync(indexDbSceneData.scene),
        loader.parseAsync(indexDbSceneData.camera),
      ]);

      // 更新场景
       store.sceneApi.scene = parseScene as THREE.Scene;
   
      //  更新相机(避免创建不必要的克隆)
      if (store.sceneApi.camera) {
        store.sceneApi.camera.clear();
        store.sceneApi.camera.copy(parseCamera as THREE.PerspectiveCamera);
      } else {
        store.sceneApi.camera= parseCamera as THREE.PerspectiveCamera;
      }

})

</script>

二. .json文件格式存储

实现原理和indexedDB存储逻辑差不多,只是将three.js场景数据存储在了 .json 格式的文件中

使three.js场景数据内容更加灵活化

如果需要涉及和后端交互则可将文件内容存储到服务端中去

1.封装一个导出场景内容的方法,将three.js场景内容数据导出json

因为如果场景数据内容过大,在导出数据过程中可能会导致页面有明显的卡顿,这里通过添加定时器(setTimeout)和 loading 来实现一个过渡效果提高用户体验

使用防抖函数来防止用户多次点击

将场景数据转换成 二进制(Blob) 然后下载为 .json 格式

const debounceExportScene = debounce(async () => {
  loading.value = true;
  loadingText.value = '导出场景中,页面可能会有卡顿请耐心等待...';
  loadingTimeout.value = setTimeout(() => {
    try {
      const newScene = cloneDeep(store.sceneApi?.scene);
      newScene?.remove(transformControlsRoot as THREE.Object3D);
      // 创建一个新的对象来存储序列化后的数据
      const jsonData = {
        scene: newScene?.toJSON(),
        camera: store.sceneApi?.camera?.toJSON(),
      };
      const blob = new Blob([JSON.stringify(jsonData)], {
        type: 'application/json',
      });
      const url = URL.createObjectURL(blob);
      const link = document.createElement('a');
      document.body.appendChild(link);
      link.href = url;
      link.download = `${new Date().toLocaleString()}.json`;
      link.click();
      document.body.removeChild(link);
      URL.revokeObjectURL(url);
      loading.value = false;
      clearTimeout(loadingTimeout.value);
 
    } catch (error) {
      loading.value = false;
      clearTimeout(loadingTimeout.value);
    }
  }, 1000);
}, 1000);


在这里插入图片描述 导出的数据文件 在这里插入图片描述

2.导入场景:将.json文件内容解析并导入到three.js场景中

通过表单获取到.json 文件的file

通过 FileReader 读取 file

将读取成功的内容通过JSON.parse() 为 json格式

最后通过 ObjectLoader 加载解析为 three.js 场景内容的数据格式

<template>
  <el-upload
      style="height: 0"
      ref="uploadSceneRef"
      accept=".json"
      :show-file-list="false"
      :auto-upload="false"
       type="hidden"
      :on-change="chooseSceneJson"
       >
   </el-upload>  
   <el-button @click="importScene">导入场景</el-button>      
</template>
<script setup lang="ts">
import {
  ElMessage,
  ElMessageBox,
  ElUpload,
  type UploadFile,
} from 'element-plus';
import { useModelStore } from '@/store/modelEditStore';
import { ObjectLoader } from 'three';

const uploadSceneRef = ref<InstanceType<typeof ElUpload>>();
const loading = ref(false);
const loadingText = ref('保存场景中...');
const store = useModelStore();

// 导入场景
const importScene = async () => {
  const input = uploadSceneRef?.value?.$el.querySelector('input');
  if (input instanceof HTMLInputElement) input.click();
};
// 选择场景json
const chooseSceneJson = async (file: UploadFile) => {
  try {
    loading.value = true;
    loadingText.value = '导入场景中...';
    const raw: File = file.raw as File;
    const reader = new FileReader();
    const fileContent = await new Promise<string>((resolve, reject) => {
      reader.onload = (e) => {
        if (e.target?.result) {
          resolve(e.target.result as string);
        } else {
          reject(new Error('文件读取失败'));
        }
      };
      reader.onerror = () => reject(new Error('文件读取失败'));
      reader.readAsText(raw);
    });
    const sceneData = JSON.parse(fileContent);
    // 验证场景数据结构
    if (!sceneData.scene || !sceneData.camera) {
      throw new Error('无效的场景文件格式');
    }

    // 加载场景数据
    if (store.sceneApi) {
          //  创建加载器(可以考虑缓存loader实例)
      const loader = new ObjectLoader();

     // 并行加载场景和相机数据
      const [parseScene, parseCamera] = await Promise.all([
        loader.parseAsync(sceneData.scene),
        loader.parseAsync(sceneData.camera),
      ]);

      // 更新场景
       store.sceneApi.scene = parseScene as THREE.Scene;
   
      //  更新相机(避免创建不必要的克隆)
      if (store.sceneApi.camera) {
        store.sceneApi.camera.clear();
        store.sceneApi.camera.copy(parseCamera as THREE.PerspectiveCamera);
      } else {
        store.sceneApi.camera= parseCamera as THREE.PerspectiveCamera;
      }
      
      ElMessage.success('场景导入成功');
   
    }
  } catch (error) {
    ElMessage.error('导入场景失败');
  } finally {
    loading.value = false;
  }
};
</script>

导入成功后的场景内容

2025_4_17 09_57_30.png

结语

ok以上就是作者探索出的three.js场景数据存储方案,如果你有更好的方案欢迎评论交流 完整的效果案例可以通过开源项目附带链接查看:

github:github.com/zhangbo126/…

gitee:gitee.com/ZHANG_6666/…

❌
❌