Threejs glTF编辑器功能详解🎯🎯🎯

最前

大家好,我是qingshun,最近心血来潮实现了一款简易版glTF编辑器,在功能实现的过程中也踩了一些坑与复习了threejs和Vue3的知识,收获颇多。

正好太久没更新了,今天就来将收获进行分享。本文将和大家一同学习如何从0到1 实现一款glTF编辑器,难度不大功能不多,但内容还算饱满,相信大家看完以后一定会有收获,内容有点长但值得你慢慢看完。

预览地址:http://www.hiwindy.cn/gltf-editor

源码地址:https://github.com/coderZqs/gltf-editor.git

项目介绍

项目是由 Vue3 + threeJS + pinia 进行开发。整个项目只有如下一个页面

左边菜单为模型的可编辑模块树形导航栏,点击切换编辑的物体部位

中间为效果展示画布,展示坐标格辅助对象与模型

右边为参数调试区域,拥有属性、变换、材质三块区域

本文将以单独模块实现 -> 各个模块交互的顺序讲解

单独模块实现

左侧树形导航栏

我们先来梳理一下功能逻辑:

从gltf模型中获取isMesh为true的物体,然后以树形结构展示出来

模型的数据结构本身就是一个树形结构,比如一个人物模型来说:最顶部对象为头、身体、下身。然后头拥有鼻子、眼睛等器官,身体拥有胸部、肚子等器官,然后每个器官又拥有自己的子器官。这样就形成了一个树形结构。

既然是树形结构那递归算法肯定会出现的,我们要思考一下递归条件:

  1. 首先不是每个结构都是需要编辑的,我们需要编辑的是(isMesh = true)网格模型。
  2. 终止条件是子对象数组是否为空
  3. 如果子对象数组不存在(isMesh)网格模型,则拿到上一轮的子对象数组作为下一轮循环的对象数组
  4. 如果子对象数组存在(isMesh)网格模型,则拿本轮的子对象数组作为下一轮的对象数组。

由此4个条件,我们很轻易就得到了以下递归函数

js 复制代码
  /**
   * 获得tree
   * @param model
   * @param ls
   */

  const getTree = (model, ls) => {
    if (model.children && model.children.length) {
      if (model.children.some(v => v.isMesh)) {
        model.children.forEach(e => {
          if (e.isMesh) {
            ls.push({ uuid: e.uuid, title: e.name, key: e.uuid, children: [] });
          }

          getTree(e, ls[ls.length - 1]?.children);
        });
      } else {
        model.children.forEach(e => {
          getTree(e, ls);
        });
      }
    }
  };

由此递归函数我们就能拿到模型中可编辑的物体。然后使用antdv中树形组件就实现左侧树形导航栏。

中间效果预览区域

首先我们需要初始化相机、光照、渲染器、场景, 初始化OrbitControl控制器、画布响应式等

这一块就不水代码了,具体可以看我项目中的封装好的 方法文件

由于需要各模块通信都会用到3D 对象(camera、Scene、renderer等),pinia工具就出现了,我们创建一个three文件用于集中化管理 threejs中的对象与方法。

js 复制代码
export default defineStore("three", () => {
  let camera: THREE.PerspectiveCamera = T.initCamera();
  let renderer: THREE.WebGLRenderer = new THREE.WebGLRenderer();
  let scene: THREE.Scene = new THREE.Scene();
  let model: Ref<THREE.Object3D> = ref();
  
   /**
   * 设置屏幕大小
   */

  const setScreenSize = dom => {
    let { width, height } = dom.getBoundingClientRect();
    renderer.setSize(width, height);
    camera.aspect = width / height;
    camera.updateProjectionMatrix();

    return { width, height };
  };

  /**
   * 初始化场景
   */

  const initScreen = dom => {
    if (renderer && camera && scene) {
      let { width, height } = setScreenSize(dom);
   
      let controls = T.addOrbitControls(camera, renderer.domElement);
      T.appendCanvasToElement(dom, renderer.domElement);
      // controls.enableDamping = true;

      window.addEventListener("resize", () => {
        setScreenSize(dom);
      });

      let animate = () => {
        requestAnimationFrame(animate);
        renderer.setClearColor(0x272822)
        renderer!.render(scene!, camera!);
        controls.update();
      };

      animate();
    }
  };
  
  return {
    camera,
    renderer,
    scene,
    model,
    initScreen,
  };
}

我们这里的步骤是:当用户上传了gltf模型并加载完成后,加载场景并将模型加入到公共对象中,并生成左侧导航栏需要的树形结构。

js 复制代码
/**
 *  文件上传事件
 */
 
const handleChange = async (info) => {
  const status = info.file.status;
  if (status === "uploading") {
    state.isLoading = true;
  }

  if (status === "done") {
    let code = info.file.response.code;
    if (code === 200) {
      let fileUrl = '/' + info.file.response.data.image;
      let e = (await T.loadGLTF(fileUrl)) as any;

      console.log(e)
      state.isLoading = false;
      state.gltfUploaded = true;
      // 发送给父元素,用于控制左右两边菜单栏显隐。
      emits("uploadSuccess", state.gltfUploaded);

      let model = e.scene.children[0];
      console.log(model)
      Scene.model = model;
      Index.current = model;
      model.position.set(0, 0, 0);
      Scene.scene!.add(model);
      
      // 需开启transparent属性,才能修改透明度
      
      model.traverse((v) => {
        if (v.isMesh) {
          v.material.transparent = true;
          v.material.opacity = 1;
        }
      })

      Index.tree = { uuid: model.uuid, key: model.uuid, title: model.name, children: [] };
      Index.getTree(model, Index.tree!.children);
      nextTick(() => {
        Scene.initScreen(canvasWrapper.value);
      });
    }
  }
};

这样完成了场景的加载。

右边参数调试区域

从上图我们可以看到有大量相同的控件,如数字输入、贴图文件上传。很轻易的就能想到将他封装起来。

为了用户的操作交互体验,编辑器中的数值输入组件通常拥有鼠标按住上下滑动修改值的功能,如下图

实现起来也是非常简单,监听鼠标点击和移动事件得出鼠标上下滑动偏移量就实现了,代码如下

js 复制代码
let props = defineProps({ modelValue: { type: Number, default: 0 }, step: { type: Number }, min: { type: Number, default: 0 }, max: { type: Number, default: 1 } });
let emits = defineEmits(["update:modelValue"])

let value = computed({
    set: (val) => {
        emits("update:modelValue", Number(val));
    },

    get: () => {
        return props.modelValue.toFixed(1);
    }
})

/**
 * 通过max min值进行value修改
 */
const formatValueByLimit = (value) => {
    if (value < props.min) {
        value = props.min
    }

    if (value > props.max) {
        value = props.max;
    }

    emits("update:modelValue", value);
}

const mousedown = (e) => {
    let sourceValue = JSON.parse(JSON.stringify(props.modelValue));
    let sourcePoiY = e.pageY;
    window.onmousemove = (moveEvent) => {
        let rY = sourcePoiY - moveEvent.pageY;
        let value = Number((sourceValue + rY * (props.step || 0.1)).toFixed(2));
        formatValueByLimit(value);
    }

    window.onmouseup = () => {
        window.onmousemove = null;
    }
}

const blur = () => {
    formatValueByLimit(props.modelValue);
}

上传组件的功能也很简单,双向绑定展示的图片,子组件内部处理上传的后修改图片的逻辑,就不贴代码了。

至此整个左、中、右布局框架就已经搭建起来了。

各模块交互

梳理逻辑

  1. 用户上传模型(model)
  2. 用户选中模块(current)
  3. 用户调整参数(right.State)
  4. 模型通过参数去修改模型自身属性(updateModelByState事件)

对,要做的就这么简单。

第一步在上面做中间区域的时候就已经完成了,保存在了pinia中。 第二步中的选中模块应该在左侧导航栏中进行操作

我们通过遍历模型判断uuid的方式去查找。

js 复制代码
const onNodeClick = (selectKey, { node }) => {
    let uuid = node.dataRef.uuid;
    Scene.model.traverse((v) => {
        if (v.uuid === uuid) {
            Index.current = v;
        }
    })
}

高亮效果

我们还需要完成一个选中高亮的效果,如下

这个后期效果在官方示例 webgl_postprocessing_outline 已经有了详细的说明。

js 复制代码
  const setOutLineEffect = (width, height) => {
    composer = new EffectComposer(renderer);
    const renderPass = new RenderPass(scene, camera);
    composer.addPass(renderPass);
    outlinePass.value = new OutlinePass(
      new THREE.Vector2(width, height),
      scene,
      camera
    );

    let params = {
      edgeStrength: 10, // 边缘强度
      edgeGlow: 1, // 边缘发光
      edgeThickness: 4, // 边缘厚度
      pulsePeriod: 5, // 脉冲周期
      rotate: false,
      usePatternTexture: false
    };

    outlinePass.value.edgeStrength = Number(params.edgeStrength);
    outlinePass.value.edgeGlow = Number(params.edgeGlow);
    outlinePass.value.edgeThickness = Number(params.edgeThickness);
    outlinePass.value.pulsePeriod = Number(params.pulsePeriod);
    outlinePass.value.visibleEdgeColor.set("#ffffff");
    outlinePass.value.hiddenEdgeColor.set("#ffffff");

    composer.addPass(toRaw(outlinePass.value));
  };

先定义一个后期组件将渲染器renderer作为参数传入,然后使用 OutlinePass 对象去实现边缘高亮效果。 Scene.outlinePass.selectedObjects = [v];

将需要高亮的物体传入数组里面就可以了。

此时我们实现了第二步

第三第四步在我们的参数调整导航栏中进行,其实就是模型与参数间的相互影响

我们定义两个函数,通过状态修改模型通过模型修改状态

js 复制代码
/**
 * 通过模型取到状态
 * @param model 
 */

const setStateByModel = (model: THREE.Mesh) => {
    colorPickValue.value = "#" + (model.material?.color.getHexString() || '000000');
    state.value.type = model.isObject3D ? "Object" : "Group";
    state.value.name = model.name;
    state.value.vertexCount = model?.geometry?.attributes?.position?.count || 0;
    state.value.triangleCount = Math.floor(state.value.vertexCount / 3 || 0);
    state.value.position = model.position.clone();
    state.value.scale = model.scale.clone();

    // 旋转弧度转角度。
    let rotation = model.rotation.clone();
    const degree2radians = (d) => d * 180 / Math.PI;
    state.value.rotation = { x: degree2radians(rotation.x), y: degree2radians(rotation.y), z: degree2radians(rotation.z) };

    if (model.material) {
        Object.keys(materialKey).forEach(async (key) => {
            if (materialKey[key] == 'Map') {
                let mapImage = model.material[key]?.image;
                if (mapImage) {
                    state.value[key] = await transImage(mapImage);
                } else {
                    state.value[key] = "";
                }
            } else {
                state.value[key] = model.material[key];
            }
        })
    }
};

我们可以看到,大部分的属性都是可以直接获取的。

但贴图这边好像做了特殊处理

state.value[key] = await transImage(mapImage);

是因为贴图是imageBitmap位图图像,文档中也写到:ImageBitmap提供了一种异步且高资源利用率的方式来为 WebGL 的渲染准备基础结构。

一开始我是想着有没有什么方法让threejs贴图不使用位图格式,答案是有的。

js 复制代码
delete window.createimagebitmap;

直接删除创建位图的方法,这样就能得到url的格式。

由于安全策略并不能展示,所以只能硬着头皮去学习imageBitmap了。

imageBitmap 可以用canvas transferFromImageBitmap 转移所有权,然后又可以通过toBlob转成blob,再通过createObjectURL 转成url地址

js 复制代码
/**
 * bmp转成image
 * @param bmp 
 */

let transImage = async (bmp) => {
    const canvas = document.createElement('canvas');
    canvas.width = bmp.width;
    canvas.height = bmp.height;
    const ctx = canvas.getContext('bitmaprenderer');
    let originalImageBitmap = await createImageBitmap(bmp);
    ctx!.transferFromImageBitmap(originalImageBitmap);
    const blob = await new Promise((res) => canvas.toBlob(res));

    canvas.remove();
    return URL.createObjectURL(blob);
}

这样我们就能得到可链接的texture地址,此时通过通过模型修改状态的阶段完成。

接下来就是通过状态修改模型了。

js 复制代码
/**
 * 通过配置设置模型
 */

const setModelByConfig = () => {
    Scene.model.traverse((v) => {
        if (v.uuid === Index.current.uuid) {
            let { x, y, z } = state.value.position;
            let { x: sx, y: sy, z: sz } = state.value.scale;
            let { x: rx, y: ry, z: rz } = state.value.rotation;
            v.position.set(x, y, z);
            v.rotation.set(rx * Math.PI / 180, ry * Math.PI / 180, rz * Math.PI / 180);
            v.scale.set(sx, sy, sz);

            if (v.material) {
                Object.keys(materialKey).forEach((key) => {
                    if (materialKey[key] !== 'Map') {
                        v.material[key] = state.value[key];
                    }
                })
            }
        }
    });
};

逻辑如出一辙,非常简单。

结尾

至此glTF 编辑器的主要逻辑都讲完了。

很简单但也算饱满,相信各位同学看完或多或少有一些收获,这正是我们都想看到的,如果同学们觉得不错的话麻烦点赞收藏关注一下吧

相关推荐
明辉光焱6 分钟前
[Electron]总结:如何创建Electron+Element Plus的项目
前端·javascript·electron
牧码岛27 分钟前
Web前端之汉字排序、sort与localeCompare的介绍、编码顺序与字典顺序的区别
前端·javascript·web·web前端
开心工作室_kaic43 分钟前
ssm111基于MVC的舞蹈网站的设计与实现+vue(论文+源码)_kaic
前端·vue.js·mvc
晨曦_子画1 小时前
用于在 .NET 中构建 Web API 的 FastEndpoints 入门
前端·.net
慧都小妮子1 小时前
Spire.PDF for .NET【页面设置】演示:在 PDF 文件中添加图像作为页面背景
前端·pdf·.net·spire.pdf
咔咔库奇1 小时前
ES6基础
前端·javascript·es6
Jiaberrr1 小时前
开启鸿蒙开发之旅:交互——点击事件
前端·华为·交互·harmonyos·鸿蒙
徐小夕1 小时前
Flowmix/Docx 多模态文档编辑器:V1.3.5版本,全面升级
前端·javascript·架构
Json____2 小时前
学法减分交管12123模拟练习小程序源码前端和后端和搭建教程
前端·后端·学习·小程序·uni-app·学法减分·驾考题库