Three.js 展示GLB文件

4,936 阅读6分钟

前言

最近这段时间再弄 GLB 文件的展示和替换纹理一类的事情。之前用的是 gltf-pipeline,但是不清楚是我没有完全读懂 gltf-pipeline 的代码还是因为他本身并不支持我需要的一些功能,所以感觉上还是不太好。刚好最近需求有更换,需要在页面上展示 GLB 文件,并且需要替换一些纹理图和增加多个 GLB 文件的功能,就尝试使用了 Three.js来实现。为了避免以后忘记,所以备份一下。

引入Three.js

因为项目不大,所以并不需要引入Three.js的东西。况且这个项目暂时也不打算使用 vue 来写,所以就按照了普通页面引入js的方式引入了Three.js。当然,还是建议大家可以去 Github 上直接下载 Three.js 同时可以再官网看一下他的一些案例 Examples

引入方法较为简单

<script type="module">
  import * as THREE from "./js/three.module.js";
  import { OrbitControls } from "./js/OrbitControls.js";
  import { GLTFLoader } from "./js/GLTFLoader.js";
  import { GLTFExporter } from "./js/exporters/GLTFExporter.js";
</script>

我这里的引入方法跟官网的例子不同

官网例子:

<script type="importmap">
    {
        "imports": {
            "three": "../build/three.module.js"
        }
    }
</script>

<script type="module">

    import * as THREE from 'three';

    import Stats from './jsm/libs/stats.module.js';

    import { OrbitControls } from './jsm/controls/OrbitControls.js';
    import { RoomEnvironment } from './jsm/environments/RoomEnvironment.js';

    import { GLTFLoader } from './jsm/loaders/GLTFLoader.js';
    import { DRACOLoader } from './jsm/loaders/DRACOLoader.js';

</script>

理论上官网例子更好,直接把 Three.js 统一引用了一下,后面的其他 js或者页面需要 引入的 Three.js的话直接使用 three 就好了,但是我不清楚是否是我书写问题,导致在 火狐 和 Safari 下 读取不到前面的 three的引用。导致这两个浏览器直接黑屏,但是 Chrome 是能够正常显示的。

定义一些需要的属性

    let scene, renderer, camera, dirLight;
    let bodyModel, glassesModel, faceModel;
    let oldmap;
    let gltfModelList = [],
        addGltfModel = [];
    const container = document.getElementById("container");
    let glbList = [
        {
          model: "glassesModel",
          url: "models/gltf/glasses-01.glb",
          position: { x: 0, y: 1.45, z: 0.088 },
          rotation: { x: -10, y: 0, z: 0 },
        },
        {
          model: "glassesModel",
          url: "models/gltf/glasses-02.glb",
          position: { x: 0, y: 1.45, z: 0.05 },
          rotation: { x: 0, y: 0, z: 0 },
        },
        {
          model: "faceModel",
          url: "models/gltf/facewear-bandana-01.glb",
          position: { x: -0.01, y: 1.45, z: 0.06 },
          rotation: { x: -10, y: 0, z: 0 },
        },
        {
          model: "faceModel",
          url: "models/gltf/facewear-bandana-02.glb",
          position: { x: 0, y: 1.45, z: 0.06 },
          rotation: { x: 0, y: 0, z: 0 },
        },
      ];
      let listArr = [];

注意上面的 glbList 因为我这边需要点击按钮新增其他的 glb 样式,也就是假如我初始GLB是一个人物模型,此时我需要在人物模型上增加类似 眼镜模型或者面具模型,所以定义了一个数组对象。里面的 model 用于区分,因为我这里有两个眼镜模型两个面罩模型,理论上同一个人物模型,不会带两个眼镜,所以根据model 假如相同的话,那么执行替换操作。里面的 position 用于指定新增模型位置,rotation 用于操作模型的旋转角度。

接下来引入完成,开始执行操作

Three.js引入及更换glb文件

    function init() {
        scene = new THREE.Scene();
        scene.background = new THREE.Color(0xffffff); // 设置背景颜色
        // scene.fog = new THREE.Fog(0xa0a0a0, 10, 50); // 设置背景和地板是否有分界线 也就是box-shadow

        const hemiLight = new THREE.HemisphereLight(0xffffff, 0xffffff);
        hemiLight.position.set(0, 1, 0);
        scene.add(hemiLight);

        // ground,增加网格对象
        const mesh = new THREE.Mesh(
          new THREE.PlaneGeometry(100, 100),
          new THREE.MeshPhongMaterial({ color: 0x999999, depthWrite: false })
        );
        mesh.rotation.x = -Math.PI / 2;
        mesh.receiveShadow = true;
        scene.add(mesh);

        let loader = new GLTFLoader();

        // 固定的 GLB 基本模型
        loader.load("models/gltf/cs02.glb", function (gltf) {
          bodyModel = gltf.scene;
          bodyModel.traverse((obj) => {
            if (obj.isMesh) {
              oldmap = obj.material.map;
              console.log(oldmap);
              let center = new THREE.Box3()
                .setFromObject(obj)
                .getCenter(new THREE.Vector3());
              // note: for a multi-material mesh, `obj.material` may be an array,
              // in which case you'd need to set `.map` on each value.
              if (texture !== undefined) {
                obj.material.map = texture;
              }
              obj.castShadow = true;
              obj.name = "cs";
            }
          });

          gltfModelList.push(gltf.scene);
          scene.add(bodyModel);
          animate();
        });

        // 增加额外 glb 文件
        let btnGlb = document.getElementsByClassName("btnGlb");

        for (let i in btnGlb) {
          if (btnGlb[i].dataset !== undefined) {
            btnGlb[i].onclick = function () {
              const indexs = listArr.findIndex((item) => {
                return item.key === this.dataset.key;
              });
              if (indexs === -1) {
                listArr.push({ key: this.dataset.key, url: this.dataset.url });
                handleChangeGlb(this.dataset.url, "add");
              } else {
                const urlIndex = listArr.findIndex((item) => {
                  return item.url === this.dataset.url;
                });
                if (urlIndex === -1) {
                  listArr[indexs].url = this.dataset.url;
                  handleChangeGlb(this.dataset.url, "add");
                } else {
                  listArr.splice(urlIndex, 1);
                  handleChangeGlb(this.dataset.url, "remove");
                }
              }
            };
          }
        }
        function handleChangeGlb(url, str) {
          const glbs = glbList.find((item) => {
            return item.url === url;
          });
          const urlIndex = addGltfModel.findIndex((item) => {
            return item.url === glbs.url;
          });
          const modelIndex = addGltfModel.findIndex((item) => {
            return item.model === glbs.model;
          });
          if (glbs.model === "glassesModel") {
            scene.remove(glassesModel);
          } else if (glbs.model === "faceModel") {
            scene.remove(faceModel);
          }
          if (str === "add") {
            loader.load(glbs.url, function (gltf) {
              if (glbs.model === "glassesModel") {
                glassesModel = gltf.scene;
              } else if (glbs.model === "faceModel") {
                faceModel = gltf.scene;
              }

              // 控制新增GLB的坐标点
              gltf.scene.position.set(
                glbs.position.x,
                glbs.position.y,
                glbs.position.z
              );

              // 控制新增GLB的旋转角度
              let rotateX = 0,
                rotateY = 0,
                rotateZ = 0;
              function typeOf(obj) {
                return Object.prototype.toString
                  .call(obj)
                  .slice(8, -1)
                  .toLowerCase();
              }
              if (glbs.rotation !== undefined) {
                if (typeOf(glbs.rotation.x) === "number") {
                  rotateX = (glbs.rotation.x / 180) * Math.PI;
                }
                if (typeOf(glbs.rotation.y) !== undefined) {
                  rotateY = (glbs.rotation.y / 180) * Math.PI;
                }
                if (typeOf(glbs.rotation.z) !== undefined) {
                  rotateZ = (glbs.rotation.z / 180) * Math.PI;
                }
              }
              gltf.scene.rotation.set(rotateX, rotateY, rotateZ);
              if (urlIndex === -1 && modelIndex === -1) {
                addGltfModel.push({
                  url: glbs.url,
                  model: glbs.model,
                  group: gltf.scene,
                });
              } else if (urlIndex === -1 && modelIndex !== -1) {
                addGltfModel[modelIndex].group = gltf.scene;
                addGltfModel[modelIndex].url = glbs.url;
              }
              scene.add(gltf.scene);
            });
          } else {
            addGltfModel.splice(urlIndex, 1);
          }
        }

        // 设置Three.js 渲染器
        renderer = new THREE.WebGLRenderer({
          antialias: true, // true/false表示是否开启反锯齿
          preserveDrawingBuffer: true, // true/false 表示是否保存绘图缓冲
          physicalCorrectLights: false,
        });
        renderer.setPixelRatio(window.devicePixelRatio);
        renderer.setSize(container.offsetWidth, container.offsetHeight); // 设置 canvas 宽高,比例需要跟摄像机比例一致
        renderer.outputEncoding = THREE.sRGBEncoding;
        renderer.shadowMap.enabled = true;
        container.appendChild(renderer.domElement);

        // camera,设置摄像机
        camera = new THREE.PerspectiveCamera(
          12, // 表示摄像头聚力模型的位置,越小距离模型越近
          // window.innerWidth / window.innerHeight,
          container.offsetWidth / container.offsetHeight, // 设置摄像机比例,需要跟canvas宽高一致
          0.1, // 表示摄像头的深入距离,假如太大的话可能会导致过早的渗入到模型内部,导致bug
          1000 // 表示后面黑白边界虚线的模糊程度。
        );
        camera.position.set(0, 1.5, 2.8); // 控制摄像头的三个轴向越大表示距离中心点越远,可以为负数

        // 设置光源
        dirLight = new THREE.DirectionalLight(0xffffff); // 设置平行光源
        dirLight.position.set(0.1, 2, 0.7);
        dirLight.castShadow = true;
        dirLight.shadow.camera.top = 2;
        dirLight.shadow.camera.bottom = -2;
        dirLight.shadow.camera.left = -2;
        dirLight.shadow.camera.right = 2;
        dirLight.shadow.camera.near = 0.1;
        dirLight.shadow.camera.far = 20;
        scene.add(dirLight);

        // 摄像机轨迹,及拖拽页面效果
        const controls = new OrbitControls(camera, renderer.domElement);
        controls.enablePan = false; // 是否允许鼠标右键移动相机位置
        controls.enableZoom = true; // 是否允许鼠标滚轮缩放
        controls.minDistance = 2.8; // 相机最小尺寸,也就是距离最近的尺寸
        controls.maxDistance = 10; // 相机最大尺寸,也就是距离最远的尺寸
        // 极角,两个设置相同,即只能沿 X 轴转动
        controls.minPolarAngle = Math.PI / 2; // 相机的最小极角,即相机能转动的最小角度
        controls.maxPolarAngle = Math.PI / 2; // 相机的最大极角,即相机能转动的最大角度
        controls.target = new THREE.Vector3(0, 1.5, 0);

        // 设置摄像头可缩放轨迹及最大最小点
        let p1 = { x: 2.8, y: 1.5 },
          p2 = { x: 10, y: 0.83 };
        window.addEventListener("mousewheel", handlerMouseWheel) ||
          window.addEventListener("DOMMouseScroll", handlerMouseWheel) ||
          window.addEventListener("wheel", handlerMouseWheel);
        window.addEventListener("pointerdown", handlePointerDown);
        window.addEventListener("pointerup", handlePointerUp);
        function handlePointerUp(event) {
          window.removeEventListener("pointermove", handlerMouseWheel);
        }
        function handlePointerDown(event) {
          if (event.button === 1) {
            window.addEventListener("pointermove", handlerMouseWheel);
          }
        }
        function getBit(value, bit = 5) {
          let str = Number(value);
          str = str.toFixed(bit);
          return Number(str);
        }
        function handlerMouseWheel(event) {
          let radiusZ = Math.sqrt(
            getBit(
              camera.position.x * camera.position.x +
                camera.position.z * camera.position.z
            )
          );
          let theY = ((radiusZ - p2.x) * (p1.y - p2.y)) / (p1.x - p2.x) + p2.y;
          if (p2.y <= theY && theY <= p1.y) {
            camera.position.y = theY;
            controls.target.y = theY;
          }
        }
        controls.autoRotate = false;
        controls.update();
    }
    init()

到这一步的时候页面上基本完毕了但是我们发现拖动的时候模型不会转动,因为我们摄像机的关系,需要使用一个函数如下所示,这里可以去看 three.js 的摄像机具体的参数,因为我这边用的这个摄像机的关系,所以才需要加这个。有些摄像机并不需要增加这个

开启旋转等效果

    // 页面效果开启比如旋转缩放等
    function animate() {
      // Render loop
      requestAnimationFrame(animate);

      renderer.render(scene, camera);
    }

同时,我这边的需求还没有结束,因为还有个需求是需要更换人物模型的材质图,所以接下来就是更换材质图

更换材质图片

      // 设置需要更换的图片
      let btn = document.getElementsByClassName("btn");
      let texture;
      for (let i in btn) {
        if (btn[i].dataset !== undefined) {
          btn[i].onclick = function () {
            changeText(btn[i].dataset.url);
          };
        }
      }
      const changeText = (str) => {
        if (str !== undefined) {
          console.log(str);
          texture = new THREE.TextureLoader().load(str);
          texture.encoding = THREE.sRGBEncoding; // 控制颜色,不加的话会整体变白
          texture.flipY = false; // 控制贴图方向,不然整体贴图方向会变得很奇怪
        }
        if (texture !== undefined) {
          bodyModel.traverse((obj) => {
            if (obj.isMesh) {
              // note: for a multi-material mesh, `obj.material` may be an array,
              // in which case you'd need to set `.map` on each value
              obj.material.map = texture;
            }
          });
        }
        scene.add(bodyModel);
      };

更换的同时需要能够重置回之前的默认图,注意,我这边已经在上面读取glb文件时已经获取到默认图,并且已经存取下来了,名称为 oldmap 所以我这边直接更改就好了

重置材质图片


      // 点击重置按钮,重置回原来的默认图
      let resets = document.getElementsByClassName("reset")[0];
      resets.onclick = function () {
        bodyModel.traverse((obj) => {
          if (obj.isMesh) {
            // note: for a multi-material mesh, `obj.material` may be an array,
            // in which case you'd need to set `.map` on each value
            obj.material.map = oldmap;
          }
        });
        scene.add(bodyModel);
      };

接下来就是下载GLB文件

下载修改好的GLB文件


      // 下载 glb文件
      let dns = document.getElementsByClassName("downloads")[0];
      dns.onclick = function () {
        let groupArr = [];
        for (let i in addGltfModel) {
          groupArr.push(addGltfModel[i].group);
        }
        groupArr = [...gltfModelList, ...groupArr];
        exportGLTF(groupArr);
      };
      function exportGLTF(input) {
        const gltfExporter = new GLTFExporter();
        const options = {
          binary: true,
        };
        gltfExporter.parse(
          input,
          function (result) {
            if (result instanceof ArrayBuffer) {
              saveArrayBuffer(result, "scene.glb");
            } else {
              const output = JSON.stringify(result, null, 2);
              saveString(output, "scene.gltf");
            }
          },
          function (error) {},
          options
        );
      }
      function saveString(text, filename) {
        save(new Blob([text], { type: "text/plain" }), filename);
      }
      function saveArrayBuffer(buffer, filename) {
        save(
          new Blob([buffer], { type: "application/octet-stream" }),
          filename
        );
      }
      const link = document.createElement("a");
      link.style.display = "none";
      document.body.appendChild(link); // Firefox workaround, see #6594
      function save(blob, filename) {
        link.href = URL.createObjectURL(blob);
        link.download = filename;
        link.click();
        // URL.revokeObjectURL( url ); breaks Firefox...
      }

具体代码就这些,本来应该更详细些的,但是之前写完这个之后有其他项目插进来导致没有第一时间整理,到现在有些地方忘记了,假如后期有时间可能会对本章进行修改吧,也许吧,希望不鸽子